From b66fd89dd20e904dcdddf1169381d0b2b23e8d3a Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Mon, 29 Apr 2024 13:23:03 -0700 Subject: [PATCH 001/103] New Pass on Public Search * Write all services to use multiple small queries * Add in search caching with redis and object-hash * Add logging for slow queries * Delete unused fields from search views and add indexes --- .../notice-of-intent.entity.ts | 1 + .../notice-of-intent-submission.entity.ts | 2 + .../public-application-search-view.entity.ts | 26 +- .../public-application-search.service.spec.ts | 76 ++- .../public-application-search.service.ts | 461 +++++++++++------- ...lic-notice-of-intent-search-view.entity.ts | 47 +- ...ic-notice-of-intent-search.service.spec.ts | 70 ++- .../public-notice-of-intent-search.service.ts | 403 +++++++++------ .../public-notification-search-view.entity.ts | 37 +- ...public-notification-search.service.spec.ts | 70 ++- .../public-notification-search.service.ts | 329 ++++++++----- .../search/public-search.controller.spec.ts | 14 + .../public/search/public-search.controller.ts | 38 +- .../public/search/public-search.module.ts | 16 +- ...2-clean_up_public_app_public_noi_search.ts | 88 ++++ ...14434697883-rebuild_notification_search.ts | 63 +++ .../alcs/src/providers/typeorm/orm.config.ts | 1 + services/apps/alcs/src/utils/set-helper.ts | 18 + services/apps/alcs/test/mocks/mockTypes.ts | 19 + services/package-lock.json | 16 + services/package.json | 2 + 21 files changed, 1163 insertions(+), 634 deletions(-) create mode 100644 services/apps/alcs/src/providers/typeorm/migrations/1714430741432-clean_up_public_app_public_noi_search.ts create mode 100644 services/apps/alcs/src/providers/typeorm/migrations/1714434697883-rebuild_notification_search.ts create mode 100644 services/apps/alcs/src/utils/set-helper.ts diff --git a/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent.entity.ts b/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent.entity.ts index 3359c3dc8c..2ef0aa8518 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent.entity.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent.entity.ts @@ -46,6 +46,7 @@ export class NoticeOfIntent extends Base { }) hideFromPortal?: boolean; + @Index() @Column({ type: 'uuid', nullable: true }) cardUuid: string; diff --git a/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.entity.ts b/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.entity.ts index 81144bc6eb..8def865dfe 100644 --- a/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.entity.ts +++ b/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.entity.ts @@ -3,6 +3,7 @@ import { AfterLoad, Column, Entity, + Index, JoinColumn, ManyToOne, OneToMany, @@ -42,6 +43,7 @@ export class NoticeOfIntentSubmission extends Base { @PrimaryGeneratedColumn('uuid') uuid: string; + @Index() @AutoMap({}) @Column({ comment: 'File Number of attached application', diff --git a/services/apps/alcs/src/portal/public/search/application/public-application-search-view.entity.ts b/services/apps/alcs/src/portal/public/search/application/public-application-search-view.entity.ts index 82b18c61eb..6f0a112b0d 100644 --- a/services/apps/alcs/src/portal/public/search/application/public-application-search-view.entity.ts +++ b/services/apps/alcs/src/portal/public/search/application/public-application-search-view.entity.ts @@ -14,15 +14,10 @@ import { LinkedStatusType } from '../public-search.dto'; .select('app_sub.uuid', 'uuid') .addSelect('app_sub.file_number', 'file_number') .addSelect('app_sub.applicant', 'applicant') - .addSelect('app_sub.local_government_uuid', 'local_government_uuid') .addSelect('localGovernment.name', 'local_government_name') .addSelect('app_sub.type_code', 'application_type_code') .addSelect('app.date_submitted_to_alc', 'date_submitted_to_alc') - .addSelect('app.decision_date', 'decision_date') .addSelect('decision_date.outcome', 'outcome') - .addSelect('decision_date.dest_rank', 'dest_rank') - .addSelect('app.uuid', 'application_uuid') - .addSelect('app.region_code', 'application_region_code') .addSelect( 'GREATEST(status_link.effective_date, decision_date.date)', 'last_update', @@ -92,15 +87,6 @@ export class PublicApplicationSubmissionSearchView { @PrimaryColumn() uuid: string; - @ViewColumn() - applicationUuid: string; - - @ViewColumn() - lastUpdate: Date; - - @ViewColumn() - applicationRegionCode?: string; - @ViewColumn() fileNumber: string; @@ -108,7 +94,7 @@ export class PublicApplicationSubmissionSearchView { applicant?: string; @ViewColumn() - localGovernmentUuid?: string; + lastUpdate: Date; @ViewColumn() localGovernmentName?: string; @@ -116,18 +102,12 @@ export class PublicApplicationSubmissionSearchView { @ViewColumn() applicationTypeCode: string; - @ViewColumn() - status: LinkedStatusType; - @ViewColumn() dateSubmittedToAlc: Date | null; @ViewColumn() - decisionDate: Date | null; - - @ViewColumn() - destRank: number | null; + outcome: string | null; @ViewColumn() - outcome: string | null; + status: LinkedStatusType; } diff --git a/services/apps/alcs/src/portal/public/search/application/public-application-search.service.spec.ts b/services/apps/alcs/src/portal/public/search/application/public-application-search.service.spec.ts index df6412927a..d3ab14263f 100644 --- a/services/apps/alcs/src/portal/public/search/application/public-application-search.service.spec.ts +++ b/services/apps/alcs/src/portal/public/search/application/public-application-search.service.spec.ts @@ -1,8 +1,12 @@ +import { RedisService } from '@app/common/redis/redis.service'; import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { createMockQuery } from '../../../../../test/mocks/mockTypes'; +import { Application } from '../../../../alcs/application/application.entity'; import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { ApplicationSubmission } from '../../../application-submission/application-submission.entity'; import { SearchRequestDto } from '../public-search.dto'; import { PublicApplicationSubmissionSearchView } from './public-application-search-view.entity'; import { PublicApplicationSearchService } from './public-application-search.service'; @@ -13,6 +17,11 @@ describe('PublicApplicationSearchService', () => { Repository >; let mockLocalGovernmentRepository: DeepMocked>; + let mockApplicationRepository: DeepMocked>; + let mockApplicationSubmissionRepository: DeepMocked< + Repository + >; + let mockRedisService: DeepMocked; const mockSearchRequestDto: SearchRequestDto = { fileNumber: '123', @@ -36,22 +45,11 @@ describe('PublicApplicationSearchService', () => { beforeEach(async () => { mockApplicationSubmissionSearchViewRepository = createMock(); mockLocalGovernmentRepository = createMock(); + mockApplicationRepository = createMock(); + mockApplicationSubmissionRepository = createMock(); + mockRedisService = createMock(); - mockQuery = { - getMany: jest.fn().mockResolvedValue([]), - getCount: jest.fn().mockResolvedValue(0), - orderBy: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - innerJoinAndMapOne: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - setParameters: jest.fn().mockReturnThis(), - leftJoin: jest.fn().mockReturnThis(), - innerJoin: jest.fn().mockReturnThis(), - withDeleted: jest.fn().mockReturnThis(), - }; + mockQuery = createMockQuery(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -64,6 +62,18 @@ describe('PublicApplicationSearchService', () => { provide: getRepositoryToken(LocalGovernment), useValue: mockLocalGovernmentRepository, }, + { + provide: getRepositoryToken(Application), + useValue: mockApplicationRepository, + }, + { + provide: getRepositoryToken(ApplicationSubmission), + useValue: mockApplicationSubmissionRepository, + }, + { + provide: RedisService, + useValue: mockRedisService, + }, ], }).compile(); @@ -74,6 +84,11 @@ describe('PublicApplicationSearchService', () => { mockLocalGovernmentRepository.findOneByOrFail.mockResolvedValue( new LocalGovernment(), ); + + mockRedisService.getClient.mockReturnValue({ + get: async () => null, + setEx: async () => null, + } as any); }); it('should be defined', () => { @@ -81,30 +96,39 @@ describe('PublicApplicationSearchService', () => { }); it('should successfully build a query using all search parameters defined', async () => { - mockApplicationSubmissionSearchViewRepository.createQueryBuilder.mockReturnValue( - mockQuery as any, + mockApplicationRepository.find.mockResolvedValue([]); + mockApplicationRepository.createQueryBuilder.mockReturnValue(mockQuery); + mockApplicationSubmissionRepository.find.mockResolvedValue([]); + mockApplicationSubmissionRepository.createQueryBuilder.mockReturnValue( + mockQuery, ); const result = await service.searchApplications(mockSearchRequestDto); expect(result).toEqual({ data: [], total: 0 }); + expect(mockApplicationRepository.find).toHaveBeenCalledTimes(3); + expect(mockApplicationRepository.createQueryBuilder).toHaveBeenCalledTimes( + 2, + ); expect( - mockApplicationSubmissionSearchViewRepository.createQueryBuilder, - ).toBeCalledTimes(1); - expect(mockQuery.andWhere).toBeCalledTimes(10); + mockApplicationSubmissionRepository.createQueryBuilder, + ).toHaveBeenCalledTimes(3); + expect(mockQuery.andWhere).toHaveBeenCalledTimes(6); }); it('should call compileApplicationSearchQuery method correctly', async () => { - const compileApplicationSearchQuerySpy = jest - .spyOn(service as any, 'compileApplicationSearchQuery') - .mockResolvedValue(mockQuery); + const searchForFilerNumbers = jest + .spyOn(service as any, 'searchForFilerNumbers') + .mockResolvedValue(new Set('100000')); + + mockApplicationSubmissionSearchViewRepository.createQueryBuilder.mockReturnValue( + mockQuery as any, + ); const result = await service.searchApplications(mockSearchRequestDto); expect(result).toEqual({ data: [], total: 0 }); - expect(compileApplicationSearchQuerySpy).toBeCalledWith( - mockSearchRequestDto, - ); + expect(searchForFilerNumbers).toHaveBeenCalledWith(mockSearchRequestDto); expect(mockQuery.orderBy).toHaveBeenCalledTimes(1); expect(mockQuery.offset).toHaveBeenCalledTimes(1); expect(mockQuery.limit).toHaveBeenCalledTimes(1); diff --git a/services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts b/services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts index b099df1a69..990bbdd7cc 100644 --- a/services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts +++ b/services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts @@ -1,12 +1,17 @@ +import { RedisService } from '@app/common/redis/redis.service'; import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Brackets, Repository } from 'typeorm'; +import * as hash from 'object-hash'; +import { Brackets, In, Repository } from 'typeorm'; import { ApplicationDecisionComponent } from '../../../../alcs/application-decision/application-decision-v2/application-decision/component/application-decision-component.entity'; import { ApplicationDecision } from '../../../../alcs/application-decision/application-decision.entity'; +import { Application } from '../../../../alcs/application/application.entity'; import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; import { formatStringToPostgresSearchStringArrayWithWildCard } from '../../../../utils/search-helper'; +import { intersectSets } from '../../../../utils/set-helper'; import { ApplicationOwner } from '../../../application-submission/application-owner/application-owner.entity'; import { ApplicationParcel } from '../../../application-submission/application-parcel/application-parcel.entity'; +import { ApplicationSubmission } from '../../../application-submission/application-submission.entity'; import { AdvancedSearchResultDto, SearchRequestDto, @@ -22,13 +27,47 @@ export class PublicApplicationSearchService { private applicationSearchRepository: Repository, @InjectRepository(LocalGovernment) private governmentRepository: Repository, + @InjectRepository(ApplicationSubmission) + private applicationSubmissionRepository: Repository, + @InjectRepository(Application) + private applicationRepository: Repository, + private redisService: RedisService, ) {} async searchApplications( searchDto: SearchRequestDto, ): Promise> { - let query = await this.compileApplicationSearchQuery(searchDto); - query = this.compileApplicationGroupBySearchQuery(query); + const searchHash = hash(searchDto); + const searchKey = `search_public_application_${searchHash}`; + + const client = this.redisService.getClient(); + const cachedSearch = await client.get(searchKey); + + let fileNumbers = new Set(); + if (cachedSearch) { + const cachedNumbers = JSON.parse(cachedSearch) as string[]; + fileNumbers = new Set(cachedNumbers); + } else { + fileNumbers = await this.searchForFilerNumbers(searchDto); + await client.setEx( + searchKey, + 180, + JSON.stringify([...fileNumbers.values()]), + ); + } + + if (fileNumbers.size === 0) { + return { + data: [], + total: 0, + }; + } + + let query = this.applicationSearchRepository + .createQueryBuilder('appSearch') + .andWhere('appSearch.fileNumber IN(:...fileNumbers)', { + fileNumbers: [...fileNumbers.values()], + }); const sortQuery = this.compileSortQuery(searchDto); @@ -73,132 +112,241 @@ export class PublicApplicationSearchService { } } - private compileApplicationGroupBySearchQuery(query) { - query = query - // FIXME: This is a quick fix for the search performance issues. It temporarily allows - // submissions with deleted application types to be shown. For now, there are no - // deleted application types, so this should be fine, but should be fixed soon. - .withDeleted() - .groupBy( - ` - "appSearch"."uuid" - , "appSearch"."application_uuid" - , "appSearch"."application_region_code" - , "appSearch"."file_number" - , "appSearch"."applicant" - , "appSearch"."local_government_uuid" - , "appSearch"."local_government_name" - , "appSearch"."application_type_code" - , "appSearch"."status" - , "appSearch"."outcome" - , "appSearch"."dest_rank" - , "appSearch"."date_submitted_to_alc" - , "appSearch"."decision_date" - , "appSearch"."last_update" - `, - ); - return query; - } - - private async compileApplicationSearchQuery(searchDto: SearchRequestDto) { - const query = - this.applicationSearchRepository.createQueryBuilder('appSearch'); + private async searchForFilerNumbers(searchDto: SearchRequestDto) { + const promises: Promise<{ fileNumber: string }[]>[] = []; if (searchDto.fileNumber) { - query - .andWhere('appSearch.file_number = :fileNumber') - .setParameters({ fileNumber: searchDto.fileNumber ?? null }); + this.addFileNumberResults(searchDto, promises); } if (searchDto.portalStatusCodes && searchDto.portalStatusCodes.length > 0) { - query.andWhere( - "alcs.get_current_status_for_application_submission_by_uuid(appSearch.uuid) ->> 'status_type_code' IN(:...statuses)", - { - statuses: searchDto.portalStatusCodes, - }, - ); - } - - if (searchDto.decisionOutcome && searchDto.decisionOutcome.length > 0) { - query.andWhere('appSearch.outcome IN(:...outcomes)', { - outcomes: searchDto.decisionOutcome, - }); + this.addPortalStatusResults(searchDto, promises); } if (searchDto.governmentName) { - const government = await this.governmentRepository.findOneByOrFail({ - name: searchDto.governmentName, - }); - - query.andWhere( - 'appSearch.local_government_uuid = :local_government_uuid', - { - local_government_uuid: government.uuid, - }, - ); + await this.addGovernmentResults(searchDto, promises); } - if (searchDto.regionCodes && searchDto.regionCodes.length > 0) { - query.andWhere('appSearch.application_region_code IN(:...regions)', { - regions: searchDto.regionCodes, - }); + this.addRegionResults(searchDto, promises); } - this.compileSearchByNameQuery(searchDto, query); - this.compileParcelSearchQuery(searchDto, query); - this.compileDecisionSearchQuery(searchDto, query); - this.compileFileTypeSearchQuery(searchDto, query); + if (searchDto.name) { + this.addNameResults(searchDto, promises); + } - return query; - } + if (searchDto.pid || searchDto.civicAddress) { + this.addParcelResults(searchDto, promises); + } - private compileDecisionSearchQuery(searchDto: SearchRequestDto, query) { if ( searchDto.dateDecidedTo !== undefined || searchDto.dateDecidedFrom !== undefined || searchDto.decisionMakerCode !== undefined ) { - query = this.joinApplicationDecision(query); - - if (searchDto.dateDecidedFrom !== undefined) { - query = query.andWhere('decision.date >= :dateDecidedFrom', { - dateDecidedFrom: new Date(searchDto.dateDecidedFrom), - }); - } - - if (searchDto.dateDecidedTo !== undefined) { - query = query.andWhere('decision.date <= :dateDecidedTo', { - dateDecidedTo: new Date(searchDto.dateDecidedTo), - }); - } - - if (searchDto.decisionMakerCode !== undefined) { - query = query.andWhere( - 'decision.decision_maker_code = :decisionMakerCode', - { - decisionMakerCode: searchDto.decisionMakerCode, - }, - ); - } + this.addDecisionResults(searchDto, promises); } - return query; - } - private joinApplicationDecision(query: any) { - query = query.innerJoin( - ApplicationDecision, - 'decision', - 'decision.application_uuid = "appSearch"."application_uuid" AND decision.is_draft = FALSE', + if (searchDto.decisionOutcome && searchDto.decisionOutcome.length > 0) { + this.addDecisionOutcomeResults(searchDto, promises); + } + + if (searchDto.fileTypes.length > 0) { + this.addFileTypeResults(searchDto, promises); + } + + //Intersect Sets + const t0 = performance.now(); + const queryResults = await Promise.all(promises); + + const allIds: Set[] = []; + for (const result of queryResults) { + const fileNumbers = new Set(); + result.forEach((currentValue) => { + fileNumbers.add(currentValue.fileNumber); + }); + allIds.push(fileNumbers); + } + + const finalResult = intersectSets(allIds); + + const t1 = performance.now(); + this.logger.debug( + `Application pre-search search took ${t1 - t0} milliseconds.`, ); - return query; + return finalResult; } - private compileParcelSearchQuery(searchDto: SearchRequestDto, query) { - query = query.leftJoin( - ApplicationParcel, - 'parcel', - 'parcel.application_submission_uuid = appSearch.uuid', - ); + private addFileNumberResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const promise = this.applicationRepository.find({ + where: { + fileNumber: searchDto.fileNumber, + }, + select: { + fileNumber: true, + }, + }); + promises.push(promise); + } + + private addRegionResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const promise = this.applicationRepository.find({ + where: { + regionCode: In(searchDto.regionCodes!), + }, + select: { + fileNumber: true, + }, + }); + promises.push(promise); + } + + private addDecisionOutcomeResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const promise = this.applicationSearchRepository.find({ + where: { + outcome: In(searchDto.decisionOutcome!), + }, + select: { + fileNumber: true, + }, + }); + + promises.push(promise); + } + + private async addGovernmentResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const government = await this.governmentRepository.findOneByOrFail({ + name: searchDto.governmentName, + }); + + const promise = this.applicationRepository.find({ + where: { + localGovernmentUuid: government.uuid, + }, + select: { + fileNumber: true, + }, + }); + promises.push(promise); + } + + private addPortalStatusResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const promise = this.applicationSubmissionRepository + .createQueryBuilder('appSubs') + .select('appSubs.fileNumber') + .where( + "alcs.get_current_status_for_application_submission_by_uuid(appSubs.uuid) ->> 'status_type_code' IN(:...statusCodes)", + { + statusCodes: searchDto.portalStatusCodes, + }, + ) + .getMany(); + promises.push(promise); + } + + private addNameResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const formattedSearchString = + formatStringToPostgresSearchStringArrayWithWildCard(searchDto.name!); + const promise = this.applicationSubmissionRepository + .createQueryBuilder('appSub') + .select('appSub.fileNumber') + .leftJoin( + ApplicationOwner, + 'application_owner', + 'application_owner.application_submission_uuid = appSub.uuid', + ) + .andWhere( + new Brackets((qb) => + qb + .where( + "LOWER(application_owner.first_name || ' ' || application_owner.last_name) LIKE ANY (:names)", + { + names: formattedSearchString, + }, + ) + .orWhere('LOWER(application_owner.first_name) LIKE ANY (:names)', { + names: formattedSearchString, + }) + .orWhere('LOWER(application_owner.last_name) LIKE ANY (:names)', { + names: formattedSearchString, + }) + .orWhere( + 'LOWER(application_owner.organization_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ), + ), + ) + .getMany(); + promises.push(promise); + } + + private addDecisionResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + let query = this.applicationRepository + .createQueryBuilder('app') + .select('app.fileNumber') + .innerJoin( + ApplicationDecision, + 'decision', + 'decision.application_uuid = "app"."uuid" AND decision.is_draft = FALSE', + ); + + if (searchDto.dateDecidedFrom !== undefined) { + query = query.andWhere('decision.date >= :dateDecidedFrom', { + dateDecidedFrom: new Date(searchDto.dateDecidedFrom), + }); + } + + if (searchDto.dateDecidedTo !== undefined) { + query = query.andWhere('decision.date <= :dateDecidedTo', { + dateDecidedTo: new Date(searchDto.dateDecidedTo), + }); + } + + if (searchDto.decisionMakerCode !== undefined) { + query = query.andWhere( + 'decision.decision_maker_code = :decisionMakerCode', + { + decisionMakerCode: searchDto.decisionMakerCode, + }, + ); + } + promises.push(query.getMany()); + } + + private addParcelResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + let query = this.applicationSubmissionRepository + .createQueryBuilder('appSub') + .select('appSub.fileNumber') + .leftJoin( + ApplicationParcel, + 'parcel', + 'parcel.application_submission_uuid = appSub.uuid', + ); if (searchDto.pid) { query = query.andWhere('parcel.pid = :pid', { pid: searchDto.pid }); @@ -212,83 +360,44 @@ export class PublicApplicationSearchService { }, ); } - return query; - } - private compileSearchByNameQuery(searchDto: SearchRequestDto, query) { - if (searchDto.name) { - const formattedSearchString = - formatStringToPostgresSearchStringArrayWithWildCard(searchDto.name!); - - query = query - .leftJoin( - ApplicationOwner, - 'application_owner', - 'application_owner.application_submission_uuid = appSearch.uuid', - ) - .andWhere( - new Brackets((qb) => - qb - .where( - "LOWER(application_owner.first_name || ' ' || application_owner.last_name) LIKE ANY (:names)", - { - names: formattedSearchString, - }, - ) - .orWhere( - 'LOWER(application_owner.first_name) LIKE ANY (:names)', - { - names: formattedSearchString, - }, - ) - .orWhere('LOWER(application_owner.last_name) LIKE ANY (:names)', { - names: formattedSearchString, - }) - .orWhere( - 'LOWER(application_owner.organization_name) LIKE ANY (:names)', - { - names: formattedSearchString, - }, - ), - ), - ); - } - return query; + promises.push(query.getMany()); } - private compileFileTypeSearchQuery(searchDto: SearchRequestDto, query) { - if (searchDto.fileTypes.length > 0) { - // if decision is not joined yet -> join it. The join of decision happens in compileApplicationDecisionSearchQuery - if ( - searchDto.dateDecidedFrom === undefined && - searchDto.dateDecidedTo === undefined && - searchDto.decisionMakerCode === undefined - ) { - query = this.joinApplicationDecision(query); - } - - query = query.leftJoin( - ApplicationDecisionComponent, - 'decisionComponent', - 'decisionComponent.application_decision_uuid = decision.uuid', + private addFileTypeResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + let query = this.applicationRepository + .createQueryBuilder('app') + .select('app.fileNumber') + .innerJoin( + ApplicationDecision, + 'decision', + 'decision.application_uuid = "app"."uuid" AND decision.is_draft = FALSE', ); - query = query.andWhere( - new Brackets((qb) => - qb - .where('appSearch.application_type_code IN (:...typeCodes)', { + query = query.leftJoin( + ApplicationDecisionComponent, + 'decisionComponent', + 'decisionComponent.application_decision_uuid = decision.uuid', + ); + + query = query.andWhere( + new Brackets((qb) => + qb + .where('appSearch.application_type_code IN (:...typeCodes)', { + typeCodes: searchDto.fileTypes, + }) + .orWhere( + 'decisionComponent.application_decision_component_type_code IN (:...typeCodes)', + { typeCodes: searchDto.fileTypes, - }) - .orWhere( - 'decisionComponent.application_decision_component_type_code IN (:...typeCodes)', - { - typeCodes: searchDto.fileTypes, - }, - ), - ), - ); - } + }, + ), + ), + ); - return query; + promises.push(query.getMany()); } } diff --git a/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search-view.entity.ts b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search-view.entity.ts index 5fc66c5976..5737844e66 100644 --- a/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search-view.entity.ts +++ b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search-view.entity.ts @@ -1,16 +1,8 @@ -import { - DataSource, - JoinColumn, - ManyToOne, - PrimaryColumn, - ViewColumn, - ViewEntity, -} from 'typeorm'; +import { DataSource, PrimaryColumn, ViewColumn, ViewEntity } from 'typeorm'; import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; import { NoticeOfIntentDecision } from '../../../../alcs/notice-of-intent-decision/notice-of-intent-decision.entity'; import { NOI_SUBMISSION_STATUS } from '../../../../alcs/notice-of-intent/notice-of-intent-submission-status/notice-of-intent-status.dto'; import { NoticeOfIntentSubmissionToSubmissionStatus } from '../../../../alcs/notice-of-intent/notice-of-intent-submission-status/notice-of-intent-status.entity'; -import { NoticeOfIntentType } from '../../../../alcs/notice-of-intent/notice-of-intent-type/notice-of-intent-type.entity'; import { NoticeOfIntent } from '../../../../alcs/notice-of-intent/notice-of-intent.entity'; import { NoticeOfIntentSubmission } from '../../../notice-of-intent-submission/notice-of-intent-submission.entity'; import { LinkedStatusType } from '../public-search.dto'; @@ -22,15 +14,10 @@ import { LinkedStatusType } from '../public-search.dto'; .select('noi_sub.uuid', 'uuid') .addSelect('noi_sub.file_number', 'file_number') .addSelect('noi_sub.applicant', 'applicant') - .addSelect('noi_sub.local_government_uuid', 'local_government_uuid') .addSelect('localGovernment.name', 'local_government_name') .addSelect('noi_sub.type_code', 'notice_of_intent_type_code') .addSelect('noi.date_submitted_to_alc', 'date_submitted_to_alc') - .addSelect('noi.decision_date', 'decision_date') .addSelect('decision_date.outcome', 'outcome') - .addSelect('decision_date.dest_rank', 'dest_rank') - .addSelect('noi.uuid', 'notice_of_intent_uuid') - .addSelect('noi.region_code', 'notice_of_intent_region_code') .addSelect( 'GREATEST(status_link.effective_date, decision_date.date)', 'last_update', @@ -45,11 +32,6 @@ import { LinkedStatusType } from '../public-search.dto'; 'noi', 'noi.file_number = noi_sub.file_number AND noi.hide_from_portal = FALSE', ) - .innerJoinAndSelect( - NoticeOfIntentType, - 'noticeOfIntentType', - 'noi_sub.type_code = noticeOfIntentType.code', - ) .leftJoin( LocalGovernment, 'localGovernment', @@ -105,48 +87,27 @@ export class PublicNoticeOfIntentSubmissionSearchView { @PrimaryColumn() uuid: string; - @ViewColumn() - noticeOfIntentUuid: string; - - @ViewColumn() - lastUpdate: Date; - - @ViewColumn() - noticeOfIntentRegionCode?: string; - @ViewColumn() fileNumber: string; @ViewColumn() applicant?: string; - @ViewColumn() - localGovernmentUuid?: string; - @ViewColumn() localGovernmentName?: string; @ViewColumn() noticeOfIntentTypeCode: string; - @ViewColumn() - status: LinkedStatusType; - @ViewColumn() dateSubmittedToAlc: Date | null; @ViewColumn() - decisionDate: Date | null; + outcome: string | null; @ViewColumn() - destRank: number | null; + lastUpdate: Date; @ViewColumn() - outcome: string | null; - - @ManyToOne(() => NoticeOfIntentType, { - nullable: false, - }) - @JoinColumn({ name: 'notice_of_intent_type_code' }) - noticeOfIntentType: NoticeOfIntentType; + status: LinkedStatusType; } diff --git a/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.spec.ts b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.spec.ts index 680829610d..683663eaae 100644 --- a/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.spec.ts +++ b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.spec.ts @@ -1,11 +1,15 @@ +import { RedisService } from '@app/common/redis/redis.service'; import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { createMockQuery } from '../../../../../test/mocks/mockTypes'; import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { NoticeOfIntent } from '../../../../alcs/notice-of-intent/notice-of-intent.entity'; +import { NoticeOfIntentSubmission } from '../../../notice-of-intent-submission/notice-of-intent-submission.entity'; import { SearchRequestDto } from '../public-search.dto'; -import { PublicNoticeOfIntentSearchService } from './public-notice-of-intent-search.service'; import { PublicNoticeOfIntentSubmissionSearchView } from './public-notice-of-intent-search-view.entity'; +import { PublicNoticeOfIntentSearchService } from './public-notice-of-intent-search.service'; describe('PublicNoticeOfIntentSearchService', () => { let service: PublicNoticeOfIntentSearchService; @@ -13,6 +17,11 @@ describe('PublicNoticeOfIntentSearchService', () => { Repository >; let mockLocalGovernmentRepository: DeepMocked>; + let mockNOIRepository: DeepMocked>; + let mockNOISubmissionRepository: DeepMocked< + Repository + >; + let mockRedisService: DeepMocked; const mockSearchDto: SearchRequestDto = { fileNumber: '123', @@ -36,20 +45,11 @@ describe('PublicNoticeOfIntentSearchService', () => { beforeEach(async () => { mockNoticeOfIntentSubmissionSearchViewRepository = createMock(); mockLocalGovernmentRepository = createMock(); + mockNOIRepository = createMock(); + mockNOISubmissionRepository = createMock(); + mockRedisService = createMock(); - mockQuery = { - getManyAndCount: jest.fn().mockResolvedValue([[], 0]), - orderBy: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - innerJoinAndMapOne: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - setParameters: jest.fn().mockReturnThis(), - leftJoin: jest.fn().mockReturnThis(), - withDeleted: jest.fn().mockReturnThis(), - }; + mockQuery = createMockQuery(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -62,6 +62,18 @@ describe('PublicNoticeOfIntentSearchService', () => { provide: getRepositoryToken(LocalGovernment), useValue: mockLocalGovernmentRepository, }, + { + provide: getRepositoryToken(NoticeOfIntent), + useValue: mockNOIRepository, + }, + { + provide: getRepositoryToken(NoticeOfIntentSubmission), + useValue: mockNOISubmissionRepository, + }, + { + provide: RedisService, + useValue: mockRedisService, + }, ], }).compile(); @@ -72,6 +84,11 @@ describe('PublicNoticeOfIntentSearchService', () => { mockLocalGovernmentRepository.findOneByOrFail.mockResolvedValue( new LocalGovernment(), ); + + mockRedisService.getClient.mockReturnValue({ + get: async () => null, + setEx: async () => null, + } as any); }); it('should be defined', () => { @@ -80,27 +97,36 @@ describe('PublicNoticeOfIntentSearchService', () => { it('should successfully build a query using all search parameters defined', async () => { mockNoticeOfIntentSubmissionSearchViewRepository.createQueryBuilder.mockReturnValue( - mockQuery as any, + mockQuery, ); + mockNOIRepository.find.mockResolvedValue([]); + mockNOIRepository.createQueryBuilder.mockReturnValue(mockQuery); + mockNOISubmissionRepository.createQueryBuilder.mockReturnValue(mockQuery); const result = await service.searchNoticeOfIntents(mockSearchDto); expect(result).toEqual({ data: [], total: 0 }); + expect(mockNOIRepository.find).toHaveBeenCalledTimes(3); + expect(mockNOIRepository.createQueryBuilder).toHaveBeenCalledTimes(1); expect( - mockNoticeOfIntentSubmissionSearchViewRepository.createQueryBuilder, - ).toBeCalledTimes(1); - expect(mockQuery.andWhere).toBeCalledTimes(9); + mockNOISubmissionRepository.createQueryBuilder, + ).toHaveBeenCalledTimes(3); + expect(mockQuery.andWhere).toHaveBeenCalledTimes(5); }); it('should call compileNoticeOfIntentSearchQuery method correctly', async () => { - const compileApplicationSearchQuerySpy = jest - .spyOn(service as any, 'compileNoticeOfIntentSearchQuery') - .mockResolvedValue(mockQuery); + const searchForFileNumbers = jest + .spyOn(service as any, 'searchForFileNumbers') + .mockResolvedValue(new Set('100000')); + + mockNoticeOfIntentSubmissionSearchViewRepository.createQueryBuilder.mockReturnValue( + mockQuery, + ); const result = await service.searchNoticeOfIntents(mockSearchDto); expect(result).toEqual({ data: [], total: 0 }); - expect(compileApplicationSearchQuerySpy).toBeCalledWith(mockSearchDto); + expect(searchForFileNumbers).toHaveBeenCalledWith(mockSearchDto); expect(mockQuery.orderBy).toHaveBeenCalledTimes(1); expect(mockQuery.offset).toHaveBeenCalledTimes(1); expect(mockQuery.limit).toHaveBeenCalledTimes(1); diff --git a/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts index b3b70ba44b..522df077c9 100644 --- a/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts +++ b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts @@ -1,11 +1,16 @@ +import { RedisService } from '@app/common/redis/redis.service'; import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Brackets, Repository } from 'typeorm'; +import * as hash from 'object-hash'; +import { Brackets, In, Repository } from 'typeorm'; import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; import { NoticeOfIntentDecision } from '../../../../alcs/notice-of-intent-decision/notice-of-intent-decision.entity'; +import { NoticeOfIntent } from '../../../../alcs/notice-of-intent/notice-of-intent.entity'; import { formatStringToPostgresSearchStringArrayWithWildCard } from '../../../../utils/search-helper'; +import { intersectSets } from '../../../../utils/set-helper'; import { NoticeOfIntentOwner } from '../../../notice-of-intent-submission/notice-of-intent-owner/notice-of-intent-owner.entity'; import { NoticeOfIntentParcel } from '../../../notice-of-intent-submission/notice-of-intent-parcel/notice-of-intent-parcel.entity'; +import { NoticeOfIntentSubmission } from '../../../notice-of-intent-submission/notice-of-intent-submission.entity'; import { AdvancedSearchResultDto, SearchRequestDto, @@ -19,8 +24,13 @@ export class PublicNoticeOfIntentSearchService { constructor( @InjectRepository(PublicNoticeOfIntentSubmissionSearchView) private noiSearchRepository: Repository, + @InjectRepository(NoticeOfIntentSubmission) + private noiSubmissionRepository: Repository, + @InjectRepository(NoticeOfIntent) + private noiRepository: Repository, @InjectRepository(LocalGovernment) private governmentRepository: Repository, + private redisService: RedisService, ) {} async searchNoticeOfIntents( @@ -28,24 +38,53 @@ export class PublicNoticeOfIntentSearchService { ): Promise< AdvancedSearchResultDto > { - const query = await this.compileNoticeOfIntentSearchQuery(searchDto); + const searchHash = hash(searchDto); + const searchKey = `search_public_noi_${searchHash}`; + + const client = this.redisService.getClient(); + const cachedSearch = await client.get(searchKey); + + let fileNumbers = new Set(); + if (cachedSearch) { + const cachedNumbers = JSON.parse(cachedSearch) as string[]; + fileNumbers = new Set(cachedNumbers); + } else { + fileNumbers = await this.searchForFileNumbers(searchDto); + await client.setEx( + searchKey, + 180, + JSON.stringify([...fileNumbers.values()]), + ); + } - this.compileGroupBySearchQuery(query); + if (fileNumbers.size === 0) { + return { + data: [], + total: 0, + }; + } + + let query = this.noiSearchRepository + .createQueryBuilder('noiSearch') + .andWhere('noiSearch.fileNumber IN(:...fileNumbers)', { + fileNumbers: [...fileNumbers.values()], + }); const sortQuery = this.compileSortQuery(searchDto); - query + query = query .orderBy(sortQuery, searchDto.sortDirection) .offset((searchDto.page - 1) * searchDto.pageSize) .limit(searchDto.pageSize); const t0 = performance.now(); - const result = await query.getManyAndCount(); + const results = await Promise.all([query.getMany(), query.getCount()]); const t1 = performance.now(); this.logger.debug(`NOI public search took ${t1 - t0} milliseconds.`); + return { - data: result[0], - total: result[1], + data: results[0], + total: results[1], }; } @@ -72,142 +111,237 @@ export class PublicNoticeOfIntentSearchService { } } - private compileGroupBySearchQuery(query) { - query = query - // FIXME: This is a quick fix for the search performance issues. It temporarily allows - // submissions with deleted NOI types to be shown. For now, there are no - // deleted application types, so this should be fine, but should be fixed soon. - .withDeleted() - .innerJoinAndMapOne( - 'noiSearch.noticeOfIntentType', - 'noiSearch.noticeOfIntentType', - 'noticeOfIntentType', - ) - .groupBy( - ` - "noiSearch"."uuid" - , "noiSearch"."notice_of_intent_uuid" - , "noiSearch"."notice_of_intent_region_code" - , "noiSearch"."file_number" - , "noiSearch"."applicant" - , "noiSearch"."local_government_uuid" - , "noiSearch"."local_government_name" - , "noiSearch"."notice_of_intent_type_code" - , "noiSearch"."status" - , "noiSearch"."date_submitted_to_alc" - , "noiSearch"."decision_date" - , "noiSearch"."outcome" - , "noiSearch"."dest_rank" - , "noiSearch"."last_update" - , "noticeOfIntentType"."audit_deleted_date_at" - , "noticeOfIntentType"."audit_created_at" - , "noticeOfIntentType"."audit_updated_by" - , "noticeOfIntentType"."audit_updated_at" - , "noticeOfIntentType"."audit_created_by" - , "noticeOfIntentType"."short_label" - , "noticeOfIntentType"."label" - , "noticeOfIntentType"."code" - , "noticeOfIntentType"."html_description" - , "noticeOfIntentType"."portal_label" - `, - ); - return query; - } - - private async compileNoticeOfIntentSearchQuery(searchDto: SearchRequestDto) { - const query = this.noiSearchRepository.createQueryBuilder('noiSearch'); + private async searchForFileNumbers(searchDto: SearchRequestDto) { + const promises: Promise<{ fileNumber: string }[]>[] = []; if (searchDto.fileNumber) { - query - .andWhere('noiSearch.file_number = :fileNumber') - .setParameters({ fileNumber: searchDto.fileNumber ?? null }); + this.addFileNumberResults(searchDto, promises); } if (searchDto.portalStatusCodes && searchDto.portalStatusCodes.length > 0) { - query.andWhere( - "alcs.get_current_status_for_notice_of_intent_submission_by_uuid(noiSearch.uuid) ->> 'status_type_code' IN(:...statuses)", - { - statuses: searchDto.portalStatusCodes, - }, - ); + this.addPortalStatusResult(searchDto, promises); } if (searchDto.governmentName) { - const government = await this.governmentRepository.findOneByOrFail({ - name: searchDto.governmentName, - }); - - query.andWhere( - 'noiSearch.local_government_uuid = :local_government_uuid', - { - local_government_uuid: government.uuid, - }, - ); + await this.addGovernmentResults(searchDto, promises); } if (searchDto.decisionOutcome && searchDto.decisionOutcome.length > 0) { - query.andWhere('noiSearch.outcome IN(:...outcomes)', { - outcomes: searchDto.decisionOutcome, - }); + this.addDecisionOutcomeResults(searchDto, promises); } if (searchDto.regionCodes && searchDto.regionCodes.length > 0) { - query.andWhere('noiSearch.notice_of_intent_region_code IN(:...regions)', { - regions: searchDto.regionCodes, - }); + this.addRegionResults(searchDto, promises); } - this.compileSearchByNameQuery(searchDto, query); - this.compileParcelSearchQuery(searchDto, query); - this.compileDecisionSearchQuery(searchDto, query); + if (searchDto.name) { + this.addNameResults(searchDto, promises); + } - return query; - } + if (searchDto.pid || searchDto.civicAddress) { + this.addParcelResults(searchDto, promises); + } - private compileDecisionSearchQuery(searchDto: SearchRequestDto, query) { if ( searchDto.dateDecidedTo !== undefined || searchDto.dateDecidedFrom !== undefined || searchDto.decisionMakerCode !== undefined ) { - query = this.joinDecision(query); - - if (searchDto.dateDecidedFrom !== undefined) { - query = query.andWhere('decision.date >= :dateDecidedFrom', { - dateDecidedFrom: new Date(searchDto.dateDecidedFrom), - }); - } - - if (searchDto.dateDecidedTo !== undefined) { - query = query.andWhere('decision.date <= :dateDecidedTo', { - dateDecidedTo: new Date(searchDto.dateDecidedTo), - }); - } - - if (searchDto.decisionMakerCode !== undefined) { - query = query.andWhere('decision.decision_maker IS NOT NULL'); - } + this.addDecisionResults(searchDto, promises); + } + + //Intersect Sets + const t0 = performance.now(); + const queryResults = await Promise.all(promises); + + const allIds: Set[] = []; + for (const result of queryResults) { + const fileNumbers = new Set(); + result.forEach((currentValue) => { + fileNumbers.add(currentValue.fileNumber); + }); + allIds.push(fileNumbers); + } + + const finalResult = intersectSets(allIds); + + const t1 = performance.now(); + this.logger.debug(`NOI pre-search search took ${t1 - t0} milliseconds.`); + return finalResult; + } + + private addDecisionResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + let query = this.noiRepository + .createQueryBuilder('noi') + .select('noi.fileNumber') + .innerJoin( + NoticeOfIntentDecision, + 'decision', + 'decision.notice_of_intent_uuid = "noi"."uuid" AND decision.is_draft = FALSE', + ); + + if (searchDto.dateDecidedFrom !== undefined) { + query = query.andWhere('decision.date >= :dateDecidedFrom', { + dateDecidedFrom: new Date(searchDto.dateDecidedFrom), + }); + } + + if (searchDto.dateDecidedTo !== undefined) { + query = query.andWhere('decision.date <= :dateDecidedTo', { + dateDecidedTo: new Date(searchDto.dateDecidedTo), + }); + } + + if (searchDto.decisionMakerCode !== undefined) { + query = query.andWhere('decision.decision_maker IS NOT NULL'); } - return query; + promises.push(query.getMany()); } - private joinDecision(query: any) { - query = query.leftJoin( - NoticeOfIntentDecision, - 'decision', - 'decision.notice_of_intent_uuid = "noiSearch"."notice_of_intent_uuid" AND decision.is_draft = FALSE', - ); - return query; + private addFileNumberResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const promise = this.noiRepository.find({ + where: { + fileNumber: searchDto.fileNumber, + }, + select: { + fileNumber: true, + }, + }); + promises.push(promise); } - private compileParcelSearchQuery(searchDto: SearchRequestDto, query) { - if (searchDto.pid || searchDto.civicAddress) { - query = query.leftJoin( + private addPortalStatusResult( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const promise = this.noiSubmissionRepository + .createQueryBuilder('noiSubs') + .select('noiSubs.fileNumber') + .where( + "alcs.get_current_status_for_notice_of_intent_submission_by_uuid(noiSubs.uuid) ->> 'status_type_code' IN(:...statusCodes)", + { + statusCodes: searchDto.portalStatusCodes, + }, + ) + .getMany(); + promises.push(promise); + } + + private async addGovernmentResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const government = await this.governmentRepository.findOneByOrFail({ + name: searchDto.governmentName, + }); + + const promise = this.noiRepository.find({ + where: { + localGovernmentUuid: government.uuid, + }, + select: { + fileNumber: true, + }, + }); + promises.push(promise); + } + + private addDecisionOutcomeResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const promise = this.noiSearchRepository.find({ + where: { + outcome: In(searchDto.decisionOutcome!), + }, + select: { + fileNumber: true, + }, + }); + + promises.push(promise); + } + + private addRegionResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const promise = this.noiRepository.find({ + where: { + regionCode: In(searchDto.regionCodes!), + }, + select: { + fileNumber: true, + }, + }); + promises.push(promise); + } + + private addNameResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const formattedSearchString = + formatStringToPostgresSearchStringArrayWithWildCard(searchDto.name!); + const promise = this.noiSubmissionRepository + .createQueryBuilder('noiSub') + .select('noiSub.fileNumber') + .leftJoin( + NoticeOfIntentOwner, + 'notice_of_intent_owner', + 'notice_of_intent_owner.notice_of_intent_submission_uuid = noiSub.uuid', + ) + .andWhere( + new Brackets((qb) => + qb + .where( + "LOWER(notice_of_intent_owner.first_name || ' ' || notice_of_intent_owner.last_name) LIKE ANY (:names)", + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notice_of_intent_owner.first_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notice_of_intent_owner.last_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notice_of_intent_owner.organization_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ), + ), + ) + .getMany(); + promises.push(promise); + } + + private addParcelResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + let query = this.noiSubmissionRepository + .createQueryBuilder('noiSub') + .select('noiSub.fileNumber') + .leftJoin( NoticeOfIntentParcel, 'parcel', - 'parcel.notice_of_intent_submission_uuid = noiSearch.uuid', + 'parcel.notice_of_intent_submission_uuid = noiSub.uuid', ); - } if (searchDto.pid) { query = query.andWhere('parcel.pid = :pid', { pid: searchDto.pid }); @@ -221,50 +355,7 @@ export class PublicNoticeOfIntentSearchService { }, ); } - return query; - } - private compileSearchByNameQuery(searchDto: SearchRequestDto, query) { - if (searchDto.name) { - const formattedSearchString = - formatStringToPostgresSearchStringArrayWithWildCard(searchDto.name!); - - query = query - .leftJoin( - NoticeOfIntentOwner, - 'notice_of_intent_owner', - 'notice_of_intent_owner.notice_of_intent_submission_uuid = noiSearch.uuid', - ) - .andWhere( - new Brackets((qb) => - qb - .where( - "LOWER(notice_of_intent_owner.first_name || ' ' || notice_of_intent_owner.last_name) LIKE ANY (:names)", - { - names: formattedSearchString, - }, - ) - .orWhere( - 'LOWER(notice_of_intent_owner.first_name) LIKE ANY (:names)', - { - names: formattedSearchString, - }, - ) - .orWhere( - 'LOWER(notice_of_intent_owner.last_name) LIKE ANY (:names)', - { - names: formattedSearchString, - }, - ) - .orWhere( - 'LOWER(notice_of_intent_owner.organization_name) LIKE ANY (:names)', - { - names: formattedSearchString, - }, - ), - ), - ); - } - return query; + promises.push(query.getMany()); } } diff --git a/services/apps/alcs/src/portal/public/search/notification/public-notification-search-view.entity.ts b/services/apps/alcs/src/portal/public/search/notification/public-notification-search-view.entity.ts index f553def390..7d45180431 100644 --- a/services/apps/alcs/src/portal/public/search/notification/public-notification-search-view.entity.ts +++ b/services/apps/alcs/src/portal/public/search/notification/public-notification-search-view.entity.ts @@ -1,13 +1,5 @@ -import { - DataSource, - JoinColumn, - ManyToOne, - PrimaryColumn, - ViewColumn, - ViewEntity, -} from 'typeorm'; +import { DataSource, PrimaryColumn, ViewColumn, ViewEntity } from 'typeorm'; import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; -import { NotificationType } from '../../../../alcs/notification/notification-type/notification-type.entity'; import { Notification } from '../../../../alcs/notification/notification.entity'; import { NotificationSubmission } from '../../../notification-submission/notification-submission.entity'; import { LinkedStatusType } from '../public-search.dto'; @@ -19,12 +11,9 @@ import { LinkedStatusType } from '../public-search.dto'; .select('noti_sub.uuid', 'uuid') .addSelect('noti_sub.file_number', 'file_number') .addSelect('noti_sub.applicant', 'applicant') - .addSelect('noti_sub.local_government_uuid', 'local_government_uuid') .addSelect('localGovernment.name', 'local_government_name') .addSelect('noti.type_code', 'notification_type_code') .addSelect('noti.date_submitted_to_alc', 'date_submitted_to_alc') - .addSelect('noti.uuid', 'notification_uuid') - .addSelect('noti.region_code', 'notification_region_code') .addSelect( 'alcs.get_current_status_for_notification_submission_by_uuid(noti_sub.uuid)', 'status', @@ -35,11 +24,6 @@ import { LinkedStatusType } from '../public-search.dto'; 'noti', 'noti.file_number = noti_sub.file_number', ) - .innerJoinAndSelect( - NotificationType, - 'notificationType', - 'noti_sub.type_code = notificationType.code', - ) .leftJoin( LocalGovernment, 'localGovernment', @@ -54,36 +38,21 @@ export class PublicNotificationSubmissionSearchView { @PrimaryColumn() uuid: string; - @ViewColumn() - notificationUuid: string; - - @ViewColumn() - notificationRegionCode?: string; - @ViewColumn() fileNumber: string; @ViewColumn() applicant?: string; - @ViewColumn() - localGovernmentUuid?: string; - @ViewColumn() localGovernmentName?: string; @ViewColumn() notificationTypeCode: string; - @ViewColumn() - status: LinkedStatusType; - @ViewColumn() dateSubmittedToAlc: Date | null; - @ManyToOne(() => NotificationType, { - nullable: false, - }) - @JoinColumn({ name: 'notification_type_code' }) - notificationType: NotificationType; + @ViewColumn() + status: LinkedStatusType; } diff --git a/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.spec.ts b/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.spec.ts index 480ecf3c88..a9cbfcaea7 100644 --- a/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.spec.ts +++ b/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.spec.ts @@ -1,11 +1,15 @@ +import { RedisService } from '@app/common/redis/redis.service'; import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { createMockQuery } from '../../../../../test/mocks/mockTypes'; import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { Notification } from '../../../../alcs/notification/notification.entity'; +import { NotificationSubmission } from '../../../notification-submission/notification-submission.entity'; import { SearchRequestDto } from '../public-search.dto'; -import { PublicNotificationSearchService } from './public-notification-search.service'; import { PublicNotificationSubmissionSearchView } from './public-notification-search-view.entity'; +import { PublicNotificationSearchService } from './public-notification-search.service'; describe('PublicNotificationSearchService', () => { let service: PublicNotificationSearchService; @@ -13,6 +17,11 @@ describe('PublicNotificationSearchService', () => { Repository >; let mockLocalGovernmentRepository: DeepMocked>; + let mockNotificationRepository: DeepMocked>; + let mockNotificationSubmissionRepository: DeepMocked< + Repository + >; + let mockRedisService: DeepMocked; const mockSearchDto: SearchRequestDto = { fileNumber: '123', @@ -36,20 +45,11 @@ describe('PublicNotificationSearchService', () => { beforeEach(async () => { mockNotificationSubmissionSearchViewRepository = createMock(); mockLocalGovernmentRepository = createMock(); + mockNotificationRepository = createMock(); + mockNotificationSubmissionRepository = createMock(); + mockRedisService = createMock(); - mockQuery = { - getManyAndCount: jest.fn().mockResolvedValue([[], 0]), - orderBy: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - innerJoinAndMapOne: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - setParameters: jest.fn().mockReturnThis(), - leftJoin: jest.fn().mockReturnThis(), - withDeleted: jest.fn().mockReturnThis(), - }; + mockQuery = createMockQuery(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -62,6 +62,18 @@ describe('PublicNotificationSearchService', () => { provide: getRepositoryToken(LocalGovernment), useValue: mockLocalGovernmentRepository, }, + { + provide: getRepositoryToken(Notification), + useValue: mockNotificationRepository, + }, + { + provide: getRepositoryToken(NotificationSubmission), + useValue: mockNotificationSubmissionRepository, + }, + { + provide: RedisService, + useValue: mockRedisService, + }, ], }).compile(); @@ -72,6 +84,11 @@ describe('PublicNotificationSearchService', () => { mockLocalGovernmentRepository.findOneByOrFail.mockResolvedValue( new LocalGovernment(), ); + + mockRedisService.getClient.mockReturnValue({ + get: async () => null, + setEx: async () => null, + } as any); }); it('should be defined', () => { @@ -79,28 +96,35 @@ describe('PublicNotificationSearchService', () => { }); it('should successfully build a query using all search parameters defined', async () => { - mockNotificationSubmissionSearchViewRepository.createQueryBuilder.mockReturnValue( + mockNotificationRepository.find.mockResolvedValue([]); + mockNotificationSubmissionRepository.find.mockResolvedValue([]); + mockNotificationSubmissionRepository.createQueryBuilder.mockReturnValue( mockQuery as any, ); const result = await service.search(mockSearchDto); expect(result).toEqual({ data: [], total: 0 }); + expect(mockNotificationRepository.find).toHaveBeenCalledTimes(3); expect( - mockNotificationSubmissionSearchViewRepository.createQueryBuilder, - ).toBeCalledTimes(1); - expect(mockQuery.andWhere).toBeCalledTimes(7); + mockNotificationSubmissionRepository.createQueryBuilder, + ).toHaveBeenCalledTimes(4); + expect(mockQuery.andWhere).toHaveBeenCalledTimes(5); }); - it('should call compileNotificationSearchQuery method correctly', async () => { - const compileSearchQuerySpy = jest - .spyOn(service as any, 'compileNotificationSearchQuery') - .mockResolvedValue(mockQuery); + it('should call searchForFileNumbers method correctly', async () => { + const searchForFileNumbers = jest + .spyOn(service as any, 'searchForFileNumbers') + .mockResolvedValue(new Set('100000')); + + mockNotificationSubmissionSearchViewRepository.createQueryBuilder.mockReturnValue( + mockQuery as any, + ); const result = await service.search(mockSearchDto); expect(result).toEqual({ data: [], total: 0 }); - expect(compileSearchQuerySpy).toBeCalledWith(mockSearchDto); + expect(searchForFileNumbers).toHaveBeenCalledWith(mockSearchDto); expect(mockQuery.orderBy).toHaveBeenCalledTimes(1); expect(mockQuery.offset).toHaveBeenCalledTimes(1); expect(mockQuery.limit).toHaveBeenCalledTimes(1); diff --git a/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts b/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts index cada752d9d..9b44b4f3d2 100644 --- a/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts +++ b/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts @@ -1,9 +1,14 @@ +import { RedisService } from '@app/common/redis/redis.service'; import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Brackets, Repository } from 'typeorm'; +import * as hash from 'object-hash'; +import { Brackets, In, Repository } from 'typeorm'; import { LocalGovernment } from '../../../../alcs/local-government/local-government.entity'; +import { Notification } from '../../../../alcs/notification/notification.entity'; import { formatStringToPostgresSearchStringArrayWithWildCard } from '../../../../utils/search-helper'; +import { intersectSets } from '../../../../utils/set-helper'; import { NotificationParcel } from '../../../notification-submission/notification-parcel/notification-parcel.entity'; +import { NotificationSubmission } from '../../../notification-submission/notification-submission.entity'; import { NotificationTransferee } from '../../../notification-submission/notification-transferee/notification-transferee.entity'; import { AdvancedSearchResultDto, @@ -17,9 +22,14 @@ export class PublicNotificationSearchService { constructor( @InjectRepository(PublicNotificationSubmissionSearchView) - private notificationSearchViewRepo: Repository, + private notificationSearchRepo: Repository, @InjectRepository(LocalGovernment) private governmentRepository: Repository, + @InjectRepository(Notification) + private notificationRepository: Repository, + @InjectRepository(NotificationSubmission) + private notificationSubRepository: Repository, + private redisService: RedisService, ) {} async search( @@ -27,9 +37,37 @@ export class PublicNotificationSearchService { ): Promise< AdvancedSearchResultDto > { - let query = await this.compileNotificationSearchQuery(searchDto); + const searchHash = hash(searchDto); + const searchKey = `search_public_notification_${searchHash}`; - query = this.compileGroupBySearchQuery(query); + const client = this.redisService.getClient(); + const cachedSearch = await client.get(searchKey); + + let fileNumbers = new Set(); + if (cachedSearch) { + const cachedNumbers = JSON.parse(cachedSearch) as string[]; + fileNumbers = new Set(cachedNumbers); + } else { + fileNumbers = await this.searchForFileNumbers(searchDto); + await client.setEx( + searchKey, + 180, + JSON.stringify([...fileNumbers.values()]), + ); + } + + if (fileNumbers.size === 0) { + return { + data: [], + total: 0, + }; + } + + let query = this.notificationSearchRepo + .createQueryBuilder('notificationSearch') + .andWhere('notificationSearch.fileNumber IN(:...fileNumbers)', { + fileNumbers: [...fileNumbers.values()], + }); const sortQuery = this.compileSortQuery(searchDto); @@ -39,14 +77,15 @@ export class PublicNotificationSearchService { .limit(searchDto.pageSize); const t0 = performance.now(); - const result = await query.getManyAndCount(); + const results = await Promise.all([query.getMany(), query.getCount()]); const t1 = performance.now(); this.logger.debug( `Notification public search took ${t1 - t0} milliseconds.`, ); + return { - data: result[0], - total: result[1], + data: results[0], + total: results[1], }; } @@ -73,99 +112,184 @@ export class PublicNotificationSearchService { } } - private compileGroupBySearchQuery(query) { - query = query - // FIXME: This is a quick fix for the search performance issues. It temporarily allows - // submissions with deleted notification types to be shown. For now, there are no - // deleted application types, so this should be fine, but should be fixed soon. - .withDeleted() - .innerJoinAndMapOne( - 'notificationSearch.notificationType', - 'notificationSearch.notificationType', - 'notificationType', - ) - .groupBy( - ` - "notificationSearch"."uuid" - , "notificationSearch"."notification_uuid" - , "notificationSearch"."notification_region_code" - , "notificationSearch"."file_number" - , "notificationSearch"."applicant" - , "notificationSearch"."local_government_uuid" - , "notificationSearch"."local_government_name" - , "notificationSearch"."notification_type_code" - , "notificationSearch"."status" - , "notificationSearch"."date_submitted_to_alc" - , "notificationType"."audit_deleted_date_at" - , "notificationType"."audit_created_at" - , "notificationType"."audit_updated_by" - , "notificationType"."audit_updated_at" - , "notificationType"."audit_created_by" - , "notificationType"."short_label" - , "notificationType"."label" - , "notificationType"."code" - , "notificationType"."html_description" - , "notificationType"."portal_label" - `, - ); - return query; - } - - private async compileNotificationSearchQuery(searchDto: SearchRequestDto) { - let query = - this.notificationSearchViewRepo.createQueryBuilder('notificationSearch'); + private async searchForFileNumbers(searchDto: SearchRequestDto) { + const promises: Promise<{ fileNumber: string }[]>[] = []; if (searchDto.fileNumber) { - query = query - .andWhere('notificationSearch.file_number = :fileNumber') - .setParameters({ fileNumber: searchDto.fileNumber ?? null }); + this.addFileNumberResults(searchDto, promises); } if (searchDto.portalStatusCodes && searchDto.portalStatusCodes.length > 0) { - query = query.andWhere( - "alcs.get_current_status_for_notification_submission_by_uuid(notificationSearch.uuid) ->> 'status_type_code' IN(:...statuses)", - { - statuses: searchDto.portalStatusCodes, - }, - ); + this.addPortalStatusResults(searchDto, promises); } if (searchDto.governmentName) { - const government = await this.governmentRepository.findOneByOrFail({ - name: searchDto.governmentName, - }); - - query = query.andWhere( - 'notificationSearch.local_government_uuid = :local_government_uuid', - { - local_government_uuid: government.uuid, - }, - ); + await this.addGovernmentResults(searchDto, promises); } if (searchDto.regionCodes && searchDto.regionCodes.length > 0) { - query = query.andWhere( - 'notificationSearch.notification_region_code IN(:...regions)', + this.addRegionResults(searchDto, promises); + } + + if (searchDto.name) { + this.addNameResults(searchDto, promises); + } + + if (searchDto.pid || searchDto.civicAddress) { + this.addParcelResults(searchDto, promises); + } + + if (searchDto.pid || searchDto.civicAddress) { + this.addParcelResults(searchDto, promises); + } + + //Intersect Sets + const t0 = performance.now(); + const queryResults = await Promise.all(promises); + + const allIds: Set[] = []; + for (const result of queryResults) { + const fileNumbers = new Set(); + result.forEach((currentValue) => { + fileNumbers.add(currentValue.fileNumber); + }); + allIds.push(fileNumbers); + } + + const finalResult = intersectSets(allIds); + + const t1 = performance.now(); + this.logger.debug( + `Notification pre-search search took ${t1 - t0} milliseconds.`, + ); + return finalResult; + } + + private addFileNumberResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const promise = this.notificationRepository.find({ + where: { + fileNumber: searchDto.fileNumber, + }, + select: { + fileNumber: true, + }, + }); + promises.push(promise); + } + + private addPortalStatusResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const promise = this.notificationSubRepository + .createQueryBuilder('notiSub') + .select('notiSub.fileNumber') + .where( + "alcs.get_current_status_for_notification_submission_by_uuid(notiSub.uuid) ->> 'status_type_code' IN(:...statusCodes)", { - regions: searchDto.regionCodes, + statusCodes: searchDto.portalStatusCodes, }, - ); - } + ) + .getMany(); + promises.push(promise); + } - query = this.compileSearchByNameQuery(searchDto, query); - query = this.compileParcelSearchQuery(searchDto, query); + private async addGovernmentResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const government = await this.governmentRepository.findOneByOrFail({ + name: searchDto.governmentName, + }); - return query; + const promise = this.notificationRepository.find({ + where: { + localGovernmentUuid: government.uuid, + }, + select: { + fileNumber: true, + }, + }); + promises.push(promise); } - private compileParcelSearchQuery(searchDto: SearchRequestDto, query) { - if (searchDto.pid || searchDto.civicAddress) { - query = query.leftJoin( + private addRegionResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const promise = this.notificationRepository.find({ + where: { + regionCode: In(searchDto.regionCodes!), + }, + select: { + fileNumber: true, + }, + }); + promises.push(promise); + } + + private addNameResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + const formattedSearchString = + formatStringToPostgresSearchStringArrayWithWildCard(searchDto.name!); + const promise = this.notificationSubRepository + .createQueryBuilder('notiSub') + .select('notiSub.fileNumber') + .leftJoin( + NotificationTransferee, + 'notification_transferee', + 'notification_transferee.notification_submission_uuid = notiSub.uuid', + ) + .andWhere( + new Brackets((qb) => + qb + .where( + "LOWER(notification_transferee.first_name || ' ' || notification_transferee.last_name) LIKE ANY (:names)", + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notification_transferee.first_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notification_transferee.last_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ) + .orWhere( + 'LOWER(notification_transferee.organization_name) LIKE ANY (:names)', + { + names: formattedSearchString, + }, + ), + ), + ) + .getMany(); + promises.push(promise); + } + + private addParcelResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + let query = this.notificationSubRepository + .createQueryBuilder('notiSub') + .select('notiSub.fileNumber') + .leftJoin( NotificationParcel, 'parcel', - 'parcel.notification_submission_uuid = notificationSearch.uuid', + 'parcel.notification_submission_uuid = notiSub.uuid', ); - } if (searchDto.pid) { query = query.andWhere('parcel.pid = :pid', { pid: searchDto.pid }); @@ -179,50 +303,7 @@ export class PublicNotificationSearchService { }, ); } - return query; - } - private compileSearchByNameQuery(searchDto: SearchRequestDto, query) { - if (searchDto.name) { - const formattedSearchString = - formatStringToPostgresSearchStringArrayWithWildCard(searchDto.name!); - - query = query - .leftJoin( - NotificationTransferee, - 'notification_transferee', - 'notification_transferee.notification_submission_uuid = notificationSearch.uuid', - ) - .andWhere( - new Brackets((qb) => - qb - .where( - "LOWER(notification_transferee.first_name || ' ' || notification_transferee.last_name) LIKE ANY (:names)", - { - names: formattedSearchString, - }, - ) - .orWhere( - 'LOWER(notification_transferee.first_name) LIKE ANY (:names)', - { - names: formattedSearchString, - }, - ) - .orWhere( - 'LOWER(notification_transferee.last_name) LIKE ANY (:names)', - { - names: formattedSearchString, - }, - ) - .orWhere( - 'LOWER(notification_transferee.organization_name) LIKE ANY (:names)', - { - names: formattedSearchString, - }, - ), - ), - ); - } - return query; + promises.push(query.getMany()); } } diff --git a/services/apps/alcs/src/portal/public/search/public-search.controller.spec.ts b/services/apps/alcs/src/portal/public/search/public-search.controller.spec.ts index f3fcc7c730..efa7d20c1d 100644 --- a/services/apps/alcs/src/portal/public/search/public-search.controller.spec.ts +++ b/services/apps/alcs/src/portal/public/search/public-search.controller.spec.ts @@ -7,6 +7,8 @@ import { ClsService } from 'nestjs-cls'; import { Repository } from 'typeorm'; import { mockKeyCloakProviders } from '../../../../test/mocks/mockTypes'; import { ApplicationType } from '../../../alcs/code/application-code/application-type/application-type.entity'; +import { NoticeOfIntentType } from '../../../alcs/notice-of-intent/notice-of-intent-type/notice-of-intent-type.entity'; +import { NotificationType } from '../../../alcs/notification/notification-type/notification-type.entity'; import { PublicApplicationSearchService } from './application/public-application-search.service'; import { PublicNoticeOfIntentSearchService } from './notice-of-intent/public-notice-of-intent-search.service'; import { PublicNotificationSearchService } from './notification/public-notification-search.service'; @@ -19,12 +21,16 @@ describe('PublicSearchController', () => { let mockAppPublicSearchService: DeepMocked; let mockNotiPublicSearchService: DeepMocked; let mockAppTypeRepo: DeepMocked>; + let mockNOITypeRepo: DeepMocked>; + let mockNotificationTypeRepo: DeepMocked>; beforeEach(async () => { mockNOIPublicSearchService = createMock(); mockAppPublicSearchService = createMock(); mockNotiPublicSearchService = createMock(); mockAppTypeRepo = createMock(); + mockNOITypeRepo = createMock(); + mockNotificationTypeRepo = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -49,6 +55,14 @@ describe('PublicSearchController', () => { provide: getRepositoryToken(ApplicationType), useValue: mockAppTypeRepo, }, + { + provide: getRepositoryToken(NoticeOfIntentType), + useValue: mockNOITypeRepo, + }, + { + provide: getRepositoryToken(NotificationType), + useValue: mockNotificationTypeRepo, + }, { provide: ClsService, useValue: {}, diff --git a/services/apps/alcs/src/portal/public/search/public-search.controller.ts b/services/apps/alcs/src/portal/public/search/public-search.controller.ts index 1a6c74ac11..dc3bc8206a 100644 --- a/services/apps/alcs/src/portal/public/search/public-search.controller.ts +++ b/services/apps/alcs/src/portal/public/search/public-search.controller.ts @@ -5,6 +5,8 @@ import { InjectMapper } from 'automapper-nestjs'; import { Public } from 'nest-keycloak-connect'; import { Repository } from 'typeorm'; import { ApplicationType } from '../../../alcs/code/application-code/application-type/application-type.entity'; +import { NoticeOfIntentType } from '../../../alcs/notice-of-intent/notice-of-intent-type/notice-of-intent-type.entity'; +import { NotificationType } from '../../../alcs/notification/notification-type/notification-type.entity'; import { isStringSetAndNotEmpty } from '../../../utils/string-helper'; import { APPLICATION_SUBMISSION_TYPES } from '../../pdf-generation/generate-submission-document.service'; import { PublicApplicationSubmissionSearchView } from './application/public-application-search-view.entity'; @@ -32,6 +34,10 @@ export class PublicSearchController { private notificationSearchService: PublicNotificationSearchService, @InjectRepository(ApplicationType) private appTypeRepo: Repository, + @InjectRepository(NoticeOfIntentType) + private noiTypeRepo: Repository, + @InjectRepository(NotificationType) + private notificationTypeRepo: Repository, ) {} @Post('/') @@ -211,18 +217,40 @@ export class PublicSearchController { const mappedNoticeOfIntents: NoticeOfIntentSearchResultDto[] = []; if (noticeOfIntents && noticeOfIntents.data.length > 0) { + const noiTypes = await this.noiTypeRepo.find({ + select: { + code: true, + label: true, + }, + }); + const noiTypeMap = new Map(); + for (const type of noiTypes) { + noiTypeMap.set(type.code, type); + } + mappedNoticeOfIntents.push( ...noticeOfIntents.data.map((noi) => - this.mapNoticeOfIntentToSearchResult(noi), + this.mapNoticeOfIntentToSearchResult(noi, noiTypeMap), ), ); } const mappedNotifications: NotificationSearchResultDto[] = []; if (notifications && notifications.data && notifications.data.length > 0) { + const notificationTypes = await this.notificationTypeRepo.find({ + select: { + code: true, + label: true, + }, + }); + const notificationTypeMap = new Map(); + for (const type of notificationTypes) { + notificationTypeMap.set(type.code, type); + } + mappedNotifications.push( ...notifications.data.map((notification) => - this.mapNotificationToSearchResult(notification), + this.mapNotificationToSearchResult(notification, notificationTypeMap), ), ); } @@ -256,13 +284,14 @@ export class PublicSearchController { private mapNoticeOfIntentToSearchResult( noi: PublicNoticeOfIntentSubmissionSearchView, + noiTypeMap: Map, ): NoticeOfIntentSearchResultDto { return { referenceId: noi.fileNumber, fileNumber: noi.fileNumber, lastUpdate: noi.lastUpdate?.getTime(), dateSubmitted: noi.dateSubmittedToAlc?.getTime(), - type: noi.noticeOfIntentType.label, + type: noiTypeMap.get(noi.noticeOfIntentTypeCode)!.label, localGovernmentName: noi.localGovernmentName, ownerName: noi.applicant, class: 'NOI', @@ -272,13 +301,14 @@ export class PublicSearchController { private mapNotificationToSearchResult( notification: PublicNotificationSubmissionSearchView, + notificationTypeMap: Map, ): NotificationSearchResultDto { return { referenceId: notification.fileNumber, fileNumber: notification.fileNumber, lastUpdate: notification.status.effective_date, dateSubmitted: notification.dateSubmittedToAlc?.getTime(), - type: notification.notificationType.label, + type: notificationTypeMap.get(notification.notificationTypeCode)!.label, localGovernmentName: notification.localGovernmentName, ownerName: notification.applicant, class: 'NOTI', diff --git a/services/apps/alcs/src/portal/public/search/public-search.module.ts b/services/apps/alcs/src/portal/public/search/public-search.module.ts index 7777fc8ffa..fb9ff9f462 100644 --- a/services/apps/alcs/src/portal/public/search/public-search.module.ts +++ b/services/apps/alcs/src/portal/public/search/public-search.module.ts @@ -3,24 +3,34 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Application } from '../../../alcs/application/application.entity'; import { ApplicationType } from '../../../alcs/code/application-code/application-type/application-type.entity'; import { LocalGovernment } from '../../../alcs/local-government/local-government.entity'; +import { NoticeOfIntentType } from '../../../alcs/notice-of-intent/notice-of-intent-type/notice-of-intent-type.entity'; import { NoticeOfIntent } from '../../../alcs/notice-of-intent/notice-of-intent.entity'; +import { NotificationType } from '../../../alcs/notification/notification-type/notification-type.entity'; import { Notification } from '../../../alcs/notification/notification.entity'; import { ApplicationProfile } from '../../../common/automapper/application.automapper.profile'; -import { PublicApplicationSearchService } from './application/public-application-search.service'; +import { ApplicationSubmission } from '../../application-submission/application-submission.entity'; +import { NoticeOfIntentSubmission } from '../../notice-of-intent-submission/notice-of-intent-submission.entity'; +import { NotificationSubmission } from '../../notification-submission/notification-submission.entity'; import { PublicApplicationSubmissionSearchView } from './application/public-application-search-view.entity'; -import { PublicNoticeOfIntentSearchService } from './notice-of-intent/public-notice-of-intent-search.service'; +import { PublicApplicationSearchService } from './application/public-application-search.service'; import { PublicNoticeOfIntentSubmissionSearchView } from './notice-of-intent/public-notice-of-intent-search-view.entity'; -import { PublicNotificationSearchService } from './notification/public-notification-search.service'; +import { PublicNoticeOfIntentSearchService } from './notice-of-intent/public-notice-of-intent-search.service'; import { PublicNotificationSubmissionSearchView } from './notification/public-notification-search-view.entity'; +import { PublicNotificationSearchService } from './notification/public-notification-search.service'; import { PublicSearchController } from './public-search.controller'; @Module({ imports: [ TypeOrmModule.forFeature([ Application, + ApplicationSubmission, ApplicationType, NoticeOfIntent, + NoticeOfIntentSubmission, + NoticeOfIntentType, Notification, + NotificationSubmission, + NotificationType, LocalGovernment, PublicApplicationSubmissionSearchView, PublicNoticeOfIntentSubmissionSearchView, diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1714430741432-clean_up_public_app_public_noi_search.ts b/services/apps/alcs/src/providers/typeorm/migrations/1714430741432-clean_up_public_app_public_noi_search.ts new file mode 100644 index 0000000000..cc236189c9 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1714430741432-clean_up_public_app_public_noi_search.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CleanUpPublicAppPublicNoiSearch1714430741432 + implements MigrationInterface +{ + name = 'CleanUpPublicAppPublicNoiSearch1714430741432'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "alcs"."typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'public_application_submission_search_view', 'alcs'], + ); + await queryRunner.query( + `DROP VIEW "alcs"."public_application_submission_search_view"`, + ); + await queryRunner.query( + `DELETE FROM "alcs"."typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'public_notice_of_intent_submission_search_view', 'alcs'], + ); + await queryRunner.query( + `DROP VIEW "alcs"."public_notice_of_intent_submission_search_view"`, + ); + + await queryRunner.query( + `CREATE VIEW "alcs"."public_notice_of_intent_submission_search_view" AS SELECT "noi_sub"."uuid" AS "uuid", "noi_sub"."applicant" AS "applicant", "localGovernment"."name" AS "local_government_name", "noi_sub"."file_number" AS "file_number", "noi_sub"."type_code" AS "notice_of_intent_type_code", "noi"."date_submitted_to_alc" AS "date_submitted_to_alc", decision_date.outcome AS "outcome", GREATEST(status_link.effective_date, decision_date.date) AS "last_update", alcs.get_current_status_for_notice_of_intent_submission_by_uuid("noi_sub"."uuid") AS "status" FROM "alcs"."notice_of_intent_submission" "noi_sub" INNER JOIN "alcs"."notice_of_intent" "noi" ON "noi"."file_number" = "noi_sub"."file_number" AND "noi"."hide_from_portal" = FALSE AND "noi"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "noi_sub"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL LEFT JOIN (SELECT MAX("effective_date") AS "effective_date", submission_uuid AS "submission_uuid" FROM "alcs"."notice_of_intent_submission_to_submission_status" "status_link" GROUP BY submission_uuid) "status_link" ON status_link."submission_uuid" = "noi_sub"."uuid" LEFT JOIN (SELECT DISTINCT ON (notice_of_intentuuid) decisiondate AS "date", outcome AS "outcome", dest_rank AS "dest_rank", notice_of_intentuuid AS "notice_of_intent_uuid" FROM (SELECT outcome_code AS "outcome", date AS "decisiondate", notice_of_intent_uuid AS "notice_of_intentuuid", RANK() OVER (PARTITION BY notice_of_intent_uuid ORDER BY date DESC, audit_created_at DESC) AS "dest_rank" FROM "alcs"."notice_of_intent_decision" "decision" WHERE ( is_draft = FALSE ) AND ( "decision"."audit_deleted_date_at" IS NULL )) "decisions" WHERE dest_rank = 1) "decision_date" ON decision_date."notice_of_intent_uuid" = "noi"."uuid" WHERE ( "noi_sub"."is_draft" = FALSE AND ("noi"."date_received_all_items" IS NOT NULL AND "noi"."date_received_all_items" <= NOW()) AND alcs.get_current_status_for_notice_of_intent_submission_by_uuid("noi_sub"."uuid")->>'status_type_code' != 'CANC' ) AND ( "noi_sub"."audit_deleted_date_at" IS NULL )`, + ); + await queryRunner.query( + `INSERT INTO "alcs"."typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'alcs', + 'VIEW', + 'public_notice_of_intent_submission_search_view', + 'SELECT "noi_sub"."uuid" AS "uuid", "noi_sub"."applicant" AS "applicant", "localGovernment"."name" AS "local_government_name", "noi_sub"."file_number" AS "file_number", "noi_sub"."type_code" AS "notice_of_intent_type_code", "noi"."date_submitted_to_alc" AS "date_submitted_to_alc", decision_date.outcome AS "outcome", GREATEST(status_link.effective_date, decision_date.date) AS "last_update", alcs.get_current_status_for_notice_of_intent_submission_by_uuid("noi_sub"."uuid") AS "status" FROM "alcs"."notice_of_intent_submission" "noi_sub" INNER JOIN "alcs"."notice_of_intent" "noi" ON "noi"."file_number" = "noi_sub"."file_number" AND "noi"."hide_from_portal" = FALSE AND "noi"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "noi_sub"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL LEFT JOIN (SELECT MAX("effective_date") AS "effective_date", submission_uuid AS "submission_uuid" FROM "alcs"."notice_of_intent_submission_to_submission_status" "status_link" GROUP BY submission_uuid) "status_link" ON status_link."submission_uuid" = "noi_sub"."uuid" LEFT JOIN (SELECT DISTINCT ON (notice_of_intentuuid) decisiondate AS "date", outcome AS "outcome", dest_rank AS "dest_rank", notice_of_intentuuid AS "notice_of_intent_uuid" FROM (SELECT outcome_code AS "outcome", date AS "decisiondate", notice_of_intent_uuid AS "notice_of_intentuuid", RANK() OVER (PARTITION BY notice_of_intent_uuid ORDER BY date DESC, audit_created_at DESC) AS "dest_rank" FROM "alcs"."notice_of_intent_decision" "decision" WHERE ( is_draft = FALSE ) AND ( "decision"."audit_deleted_date_at" IS NULL )) "decisions" WHERE dest_rank = 1) "decision_date" ON decision_date."notice_of_intent_uuid" = "noi"."uuid" WHERE ( "noi_sub"."is_draft" = FALSE AND ("noi"."date_received_all_items" IS NOT NULL AND "noi"."date_received_all_items" <= NOW()) AND alcs.get_current_status_for_notice_of_intent_submission_by_uuid("noi_sub"."uuid")->>\'status_type_code\' != \'CANC\' ) AND ( "noi_sub"."audit_deleted_date_at" IS NULL )', + ], + ); + await queryRunner.query( + `CREATE VIEW "alcs"."public_application_submission_search_view" AS SELECT "app_sub"."uuid" AS "uuid", "app_sub"."applicant" AS "applicant", "localGovernment"."name" AS "local_government_name", "app_sub"."file_number" AS "file_number", "app_sub"."type_code" AS "application_type_code", "app"."date_submitted_to_alc" AS "date_submitted_to_alc", decision_date.outcome AS "outcome", GREATEST(status_link.effective_date, decision_date.date) AS "last_update", alcs.get_current_status_for_application_submission_by_uuid("app_sub"."uuid") AS "status" FROM "alcs"."application_submission" "app_sub" INNER JOIN "alcs"."application" "app" ON "app"."file_number" = "app_sub"."file_number" AND "app"."hide_from_portal" = FALSE AND "app"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "app_sub"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL LEFT JOIN (SELECT MAX("effective_date") AS "effective_date", submission_uuid AS "submission_uuid" FROM "alcs"."application_submission_to_submission_status" "status_link" GROUP BY submission_uuid) "status_link" ON status_link."submission_uuid" = "app_sub"."uuid" LEFT JOIN (SELECT DISTINCT ON (application_uuid) decisiondate AS "date", outcome AS "outcome", dest_rank AS "dest_rank", applicationuuid AS "application_uuid" FROM (SELECT outcome_code AS "outcome", date AS "decisiondate", application_uuid AS "applicationuuid", RANK() OVER (PARTITION BY application_uuid ORDER BY date DESC, audit_created_at DESC) AS "dest_rank" FROM "alcs"."application_decision" "decision" WHERE ( is_draft = FALSE ) AND ( "decision"."audit_deleted_date_at" IS NULL )) "decisions" WHERE dest_rank = 1) "decision_date" ON decision_date."application_uuid" = "app"."uuid" WHERE ( "app_sub"."is_draft" = FALSE AND ("app"."date_received_all_items" IS NOT NULL AND "app"."date_received_all_items" <= NOW()) AND alcs.get_current_status_for_application_submission_by_uuid("app_sub"."uuid")->>'status_type_code' != 'CANC' ) AND ( "app_sub"."audit_deleted_date_at" IS NULL )`, + ); + await queryRunner.query( + `INSERT INTO "alcs"."typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'alcs', + 'VIEW', + 'public_application_submission_search_view', + 'SELECT "app_sub"."uuid" AS "uuid", "app_sub"."applicant" AS "applicant", "localGovernment"."name" AS "local_government_name", "app_sub"."file_number" AS "file_number", "app_sub"."type_code" AS "application_type_code", "app"."date_submitted_to_alc" AS "date_submitted_to_alc", decision_date.outcome AS "outcome", GREATEST(status_link.effective_date, decision_date.date) AS "last_update", alcs.get_current_status_for_application_submission_by_uuid("app_sub"."uuid") AS "status" FROM "alcs"."application_submission" "app_sub" INNER JOIN "alcs"."application" "app" ON "app"."file_number" = "app_sub"."file_number" AND "app"."hide_from_portal" = FALSE AND "app"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "app_sub"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL LEFT JOIN (SELECT MAX("effective_date") AS "effective_date", submission_uuid AS "submission_uuid" FROM "alcs"."application_submission_to_submission_status" "status_link" GROUP BY submission_uuid) "status_link" ON status_link."submission_uuid" = "app_sub"."uuid" LEFT JOIN (SELECT DISTINCT ON (application_uuid) decisiondate AS "date", outcome AS "outcome", dest_rank AS "dest_rank", applicationuuid AS "application_uuid" FROM (SELECT outcome_code AS "outcome", date AS "decisiondate", application_uuid AS "applicationuuid", RANK() OVER (PARTITION BY application_uuid ORDER BY date DESC, audit_created_at DESC) AS "dest_rank" FROM "alcs"."application_decision" "decision" WHERE ( is_draft = FALSE ) AND ( "decision"."audit_deleted_date_at" IS NULL )) "decisions" WHERE dest_rank = 1) "decision_date" ON decision_date."application_uuid" = "app"."uuid" WHERE ( "app_sub"."is_draft" = FALSE AND ("app"."date_received_all_items" IS NOT NULL AND "app"."date_received_all_items" <= NOW()) AND alcs.get_current_status_for_application_submission_by_uuid("app_sub"."uuid")->>\'status_type_code\' != \'CANC\' ) AND ( "app_sub"."audit_deleted_date_at" IS NULL )', + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "alcs"."typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'public_application_submission_search_view', 'alcs'], + ); + await queryRunner.query( + `DROP VIEW "alcs"."public_application_submission_search_view"`, + ); + await queryRunner.query( + `DELETE FROM "alcs"."typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'public_notice_of_intent_submission_search_view', 'alcs'], + ); + await queryRunner.query( + `DROP VIEW "alcs"."public_notice_of_intent_submission_search_view"`, + ); + + await queryRunner.query( + `INSERT INTO "alcs"."typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'alcs', + 'VIEW', + 'planning_review_search_view', + 'SELECT "planning_review"."uuid" AS "uuid", "planningReviewType"."audit_deleted_date_at" AS "planningReviewType_audit_deleted_date_at", "planningReviewType"."audit_created_at" AS "planningReviewType_audit_created_at", "planningReviewType"."audit_updated_at" AS "planningReviewType_audit_updated_at", "planningReviewType"."audit_created_by" AS "planningReviewType_audit_created_by", "planningReviewType"."audit_updated_by" AS "planningReviewType_audit_updated_by", "planningReviewType"."label" AS "planningReviewType_label", "planningReviewType"."code" AS "planningReviewType_code", "planningReviewType"."description" AS "planningReviewType_description", "planningReviewType"."short_label" AS "planningReviewType_short_label", "planningReviewType"."background_color" AS "planningReviewType_background_color", "planningReviewType"."text_color" AS "planningReviewType_text_color", "planningReviewType"."html_description" AS "planningReviewType_html_description", "localGovernment"."name" AS "local_government_name", "planning_review"."file_number" AS "file_number", "planning_review"."document_name" AS "document_name", "planning_review"."local_government_uuid" AS "local_government_uuid", "planning_review"."type_code" AS "planning_review_type_code", "planning_review"."region_code" AS "region_code" FROM "alcs"."planning_review" "planning_review" INNER JOIN "alcs"."planning_review_type" "planningReviewType" ON "planning_review"."type_code" = "planningReviewType"."code" LEFT JOIN "alcs"."local_government" "localGovernment" ON "planning_review"."local_government_uuid" = "localGovernment"."uuid"', + ], + ); + await queryRunner.query( + `CREATE VIEW "alcs"."public_notice_of_intent_submission_search_view" AS SELECT "noi_sub"."uuid" AS "uuid", "noi_sub"."applicant" AS "applicant", "noi"."uuid" AS "notice_of_intent_uuid", "noticeOfIntentType"."audit_deleted_date_at" AS "noticeOfIntentType_audit_deleted_date_at", "noticeOfIntentType"."audit_created_at" AS "noticeOfIntentType_audit_created_at", "noticeOfIntentType"."audit_updated_at" AS "noticeOfIntentType_audit_updated_at", "noticeOfIntentType"."audit_created_by" AS "noticeOfIntentType_audit_created_by", "noticeOfIntentType"."audit_updated_by" AS "noticeOfIntentType_audit_updated_by", "noticeOfIntentType"."label" AS "noticeOfIntentType_label", "noticeOfIntentType"."code" AS "noticeOfIntentType_code", "noticeOfIntentType"."description" AS "noticeOfIntentType_description", "noticeOfIntentType"."short_label" AS "noticeOfIntentType_short_label", "noticeOfIntentType"."background_color" AS "noticeOfIntentType_background_color", "noticeOfIntentType"."text_color" AS "noticeOfIntentType_text_color", "noticeOfIntentType"."html_description" AS "noticeOfIntentType_html_description", "noticeOfIntentType"."portal_label" AS "noticeOfIntentType_portal_label", "noticeOfIntentType"."alc_fee_amount" AS "noticeOfIntentType_alc_fee_amount", "noticeOfIntentType"."government_fee_amount" AS "noticeOfIntentType_government_fee_amount", "localGovernment"."name" AS "local_government_name", "noi_sub"."file_number" AS "file_number", "noi_sub"."local_government_uuid" AS "local_government_uuid", "noi_sub"."type_code" AS "notice_of_intent_type_code", "noi"."date_submitted_to_alc" AS "date_submitted_to_alc", "noi"."decision_date" AS "decision_date", decision_date.outcome AS "outcome", decision_date.dest_rank AS "dest_rank", "noi"."region_code" AS "notice_of_intent_region_code", GREATEST(status_link.effective_date, decision_date.date) AS "last_update", alcs.get_current_status_for_notice_of_intent_submission_by_uuid("noi_sub"."uuid") AS "status" FROM "alcs"."notice_of_intent_submission" "noi_sub" INNER JOIN "alcs"."notice_of_intent" "noi" ON "noi"."file_number" = "noi_sub"."file_number" AND "noi"."hide_from_portal" = FALSE AND "noi"."audit_deleted_date_at" IS NULL INNER JOIN "alcs"."notice_of_intent_type" "noticeOfIntentType" ON "noi_sub"."type_code" = "noticeOfIntentType"."code" AND "noticeOfIntentType"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "noi_sub"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL LEFT JOIN (SELECT MAX("effective_date") AS "effective_date", submission_uuid AS "submission_uuid" FROM "alcs"."notice_of_intent_submission_to_submission_status" "status_link" GROUP BY submission_uuid) "status_link" ON status_link."submission_uuid" = "noi_sub"."uuid" LEFT JOIN (SELECT DISTINCT ON (notice_of_intentuuid) decisiondate AS "date", outcome AS "outcome", dest_rank AS "dest_rank", notice_of_intentuuid AS "notice_of_intent_uuid" FROM (SELECT outcome_code AS "outcome", date AS "decisiondate", notice_of_intent_uuid AS "notice_of_intentuuid", RANK() OVER (PARTITION BY notice_of_intent_uuid ORDER BY date DESC, audit_created_at DESC) AS "dest_rank" FROM "alcs"."notice_of_intent_decision" "decision" WHERE ( is_draft = FALSE ) AND ( "decision"."audit_deleted_date_at" IS NULL )) "decisions" WHERE dest_rank = 1) "decision_date" ON decision_date."notice_of_intent_uuid" = "noi"."uuid" WHERE ( "noi_sub"."is_draft" = FALSE AND ("noi"."date_received_all_items" IS NOT NULL AND "noi"."date_received_all_items" <= NOW()) AND alcs.get_current_status_for_notice_of_intent_submission_by_uuid("noi_sub"."uuid")->>'status_type_code' != 'CANC' ) AND ( "noi_sub"."audit_deleted_date_at" IS NULL )`, + ); + await queryRunner.query( + `INSERT INTO "alcs"."typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'alcs', + 'VIEW', + 'public_application_submission_search_view', + 'SELECT "app_sub"."uuid" AS "uuid", "app_sub"."applicant" AS "applicant", "app"."uuid" AS "application_uuid", "localGovernment"."name" AS "local_government_name", "app_sub"."file_number" AS "file_number", "app_sub"."local_government_uuid" AS "local_government_uuid", "app_sub"."type_code" AS "application_type_code", "app"."date_submitted_to_alc" AS "date_submitted_to_alc", "app"."decision_date" AS "decision_date", decision_date.outcome AS "outcome", decision_date.dest_rank AS "dest_rank", "app"."region_code" AS "application_region_code", GREATEST(status_link.effective_date, decision_date.date) AS "last_update", alcs.get_current_status_for_application_submission_by_uuid("app_sub"."uuid") AS "status" FROM "alcs"."application_submission" "app_sub" INNER JOIN "alcs"."application" "app" ON "app"."file_number" = "app_sub"."file_number" AND "app"."hide_from_portal" = FALSE AND "app"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "app_sub"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL LEFT JOIN (SELECT MAX("effective_date") AS "effective_date", submission_uuid AS "submission_uuid" FROM "alcs"."application_submission_to_submission_status" "status_link" GROUP BY submission_uuid) "status_link" ON status_link."submission_uuid" = "app_sub"."uuid" LEFT JOIN (SELECT DISTINCT ON (application_uuid) decisiondate AS "date", outcome AS "outcome", dest_rank AS "dest_rank", applicationuuid AS "application_uuid" FROM (SELECT outcome_code AS "outcome", date AS "decisiondate", application_uuid AS "applicationuuid", RANK() OVER (PARTITION BY application_uuid ORDER BY date DESC, audit_created_at DESC) AS "dest_rank" FROM "alcs"."application_decision" "decision" WHERE ( is_draft = FALSE ) AND ( "decision"."audit_deleted_date_at" IS NULL )) "decisions" WHERE dest_rank = 1) "decision_date" ON decision_date."application_uuid" = "app"."uuid" WHERE ( "app_sub"."is_draft" = FALSE AND ("app"."date_received_all_items" IS NOT NULL AND "app"."date_received_all_items" <= NOW()) AND alcs.get_current_status_for_application_submission_by_uuid("app_sub"."uuid")->>\'status_type_code\' != \'CANC\' ) AND ( "app_sub"."audit_deleted_date_at" IS NULL )', + ], + ); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1714434697883-rebuild_notification_search.ts b/services/apps/alcs/src/providers/typeorm/migrations/1714434697883-rebuild_notification_search.ts new file mode 100644 index 0000000000..1f15d00a90 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1714434697883-rebuild_notification_search.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RebuildNotificationSearch1714434697883 + implements MigrationInterface +{ + name = 'RebuildNotificationSearch1714434697883'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "alcs"."typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'public_notification_submission_search_view', 'alcs'], + ); + await queryRunner.query( + `DROP VIEW "alcs"."public_notification_submission_search_view"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2f863dc7ab4f76a87b160f3288" ON "alcs"."notice_of_intent" ("card_uuid") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_07e0aa0c43cb5a2bfc2c00282d" ON "alcs"."notice_of_intent_submission" ("file_number") `, + ); + await queryRunner.query( + `CREATE VIEW "alcs"."public_notification_submission_search_view" AS SELECT "noti_sub"."uuid" AS "uuid", "noti_sub"."applicant" AS "applicant", "localGovernment"."name" AS "local_government_name", "noti_sub"."file_number" AS "file_number", "noti"."type_code" AS "notification_type_code", "noti"."date_submitted_to_alc" AS "date_submitted_to_alc", alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid") AS "status" FROM "alcs"."notification_submission" "noti_sub" INNER JOIN "alcs"."notification" "noti" ON "noti"."file_number" = "noti_sub"."file_number" AND "noti"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "noti"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL WHERE ( alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid")->>'status_type_code' = 'ALCR' ) AND ( "noti_sub"."audit_deleted_date_at" IS NULL )`, + ); + await queryRunner.query( + `INSERT INTO "alcs"."typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'alcs', + 'VIEW', + 'public_notification_submission_search_view', + 'SELECT "noti_sub"."uuid" AS "uuid", "noti_sub"."applicant" AS "applicant", "localGovernment"."name" AS "local_government_name", "noti_sub"."file_number" AS "file_number", "noti"."type_code" AS "notification_type_code", "noti"."date_submitted_to_alc" AS "date_submitted_to_alc", alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid") AS "status" FROM "alcs"."notification_submission" "noti_sub" INNER JOIN "alcs"."notification" "noti" ON "noti"."file_number" = "noti_sub"."file_number" AND "noti"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "noti"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL WHERE ( alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid")->>\'status_type_code\' = \'ALCR\' ) AND ( "noti_sub"."audit_deleted_date_at" IS NULL )', + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "alcs"."typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'public_notification_submission_search_view', 'alcs'], + ); + await queryRunner.query( + `DROP VIEW "alcs"."public_notification_submission_search_view"`, + ); + await queryRunner.query( + `DROP INDEX "alcs"."IDX_07e0aa0c43cb5a2bfc2c00282d"`, + ); + await queryRunner.query( + `DROP INDEX "alcs"."IDX_2f863dc7ab4f76a87b160f3288"`, + ); + await queryRunner.query( + `CREATE VIEW "alcs"."public_notification_submission_search_view" AS SELECT "noti_sub"."uuid" AS "uuid", "noti_sub"."applicant" AS "applicant", "noti"."uuid" AS "notification_uuid", "notificationType"."audit_deleted_date_at" AS "notificationType_audit_deleted_date_at", "notificationType"."audit_created_at" AS "notificationType_audit_created_at", "notificationType"."audit_updated_at" AS "notificationType_audit_updated_at", "notificationType"."audit_created_by" AS "notificationType_audit_created_by", "notificationType"."audit_updated_by" AS "notificationType_audit_updated_by", "notificationType"."label" AS "notificationType_label", "notificationType"."code" AS "notificationType_code", "notificationType"."description" AS "notificationType_description", "notificationType"."short_label" AS "notificationType_short_label", "notificationType"."background_color" AS "notificationType_background_color", "notificationType"."text_color" AS "notificationType_text_color", "notificationType"."html_description" AS "notificationType_html_description", "notificationType"."portal_label" AS "notificationType_portal_label", "localGovernment"."name" AS "local_government_name", "noti_sub"."file_number" AS "file_number", "noti_sub"."local_government_uuid" AS "local_government_uuid", "noti"."type_code" AS "notification_type_code", "noti"."date_submitted_to_alc" AS "date_submitted_to_alc", "noti"."region_code" AS "notification_region_code", alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid") AS "status" FROM "alcs"."notification_submission" "noti_sub" INNER JOIN "alcs"."notification" "noti" ON "noti"."file_number" = "noti_sub"."file_number" AND "noti"."audit_deleted_date_at" IS NULL INNER JOIN "alcs"."notification_type" "notificationType" ON "noti_sub"."type_code" = "notificationType"."code" AND "notificationType"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "noti"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL WHERE ( alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid")->>'status_type_code' = 'ALCR' ) AND ( "noti_sub"."audit_deleted_date_at" IS NULL )`, + ); + await queryRunner.query( + `INSERT INTO "alcs"."typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'alcs', + 'VIEW', + 'public_notification_submission_search_view', + 'SELECT "noti_sub"."uuid" AS "uuid", "noti_sub"."applicant" AS "applicant", "noti"."uuid" AS "notification_uuid", "notificationType"."audit_deleted_date_at" AS "notificationType_audit_deleted_date_at", "notificationType"."audit_created_at" AS "notificationType_audit_created_at", "notificationType"."audit_updated_at" AS "notificationType_audit_updated_at", "notificationType"."audit_created_by" AS "notificationType_audit_created_by", "notificationType"."audit_updated_by" AS "notificationType_audit_updated_by", "notificationType"."label" AS "notificationType_label", "notificationType"."code" AS "notificationType_code", "notificationType"."description" AS "notificationType_description", "notificationType"."short_label" AS "notificationType_short_label", "notificationType"."background_color" AS "notificationType_background_color", "notificationType"."text_color" AS "notificationType_text_color", "notificationType"."html_description" AS "notificationType_html_description", "notificationType"."portal_label" AS "notificationType_portal_label", "localGovernment"."name" AS "local_government_name", "noti_sub"."file_number" AS "file_number", "noti_sub"."local_government_uuid" AS "local_government_uuid", "noti"."type_code" AS "notification_type_code", "noti"."date_submitted_to_alc" AS "date_submitted_to_alc", "noti"."region_code" AS "notification_region_code", alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid") AS "status" FROM "alcs"."notification_submission" "noti_sub" INNER JOIN "alcs"."notification" "noti" ON "noti"."file_number" = "noti_sub"."file_number" AND "noti"."audit_deleted_date_at" IS NULL INNER JOIN "alcs"."notification_type" "notificationType" ON "noti_sub"."type_code" = "notificationType"."code" AND "notificationType"."audit_deleted_date_at" IS NULL LEFT JOIN "alcs"."local_government" "localGovernment" ON "noti"."local_government_uuid" = "localGovernment"."uuid" AND "localGovernment"."audit_deleted_date_at" IS NULL WHERE ( alcs.get_current_status_for_notification_submission_by_uuid("noti_sub"."uuid")->>\'status_type_code\' = \'ALCR\' ) AND ( "noti_sub"."audit_deleted_date_at" IS NULL )', + ], + ); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/orm.config.ts b/services/apps/alcs/src/providers/typeorm/orm.config.ts index 7a9facbb2d..dba25d9749 100644 --- a/services/apps/alcs/src/providers/typeorm/orm.config.ts +++ b/services/apps/alcs/src/providers/typeorm/orm.config.ts @@ -17,6 +17,7 @@ export const getTypeOrmModuleOptions = ( namingStrategy: new SnakeNamingStrategy(), uuidExtension: 'pgcrypto', applicationName: 'alcs', + maxQueryExecutionTime: 5000, replication: { defaultMode: 'master', master: { diff --git a/services/apps/alcs/src/utils/set-helper.ts b/services/apps/alcs/src/utils/set-helper.ts new file mode 100644 index 0000000000..5e5e434030 --- /dev/null +++ b/services/apps/alcs/src/utils/set-helper.ts @@ -0,0 +1,18 @@ +export const intersectSets = (sets: Set[]): Set => { + // If there are no sets, return an empty set + if (sets.length === 0) { + return new Set(); + } + + // Start with the first set + let intersection = new Set(sets[0]); + + // Iterate over the remaining sets + for (let i = 1; i < sets.length; i++) { + intersection = new Set( + [...intersection].filter((item) => sets[i].has(item)), + ); + } + + return intersection; +}; diff --git a/services/apps/alcs/test/mocks/mockTypes.ts b/services/apps/alcs/test/mocks/mockTypes.ts index bef47f5fee..3a7cb04b8f 100644 --- a/services/apps/alcs/test/mocks/mockTypes.ts +++ b/services/apps/alcs/test/mocks/mockTypes.ts @@ -39,3 +39,22 @@ export const mockKeyCloakProviders = [ useValue: {}, }, ]; + +export const createMockQuery = () => { + return { + select: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + getCount: jest.fn().mockResolvedValue(0), + orderBy: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + innerJoinAndMapOne: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + setParameters: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + withDeleted: jest.fn().mockReturnThis(), + }; +}; diff --git a/services/package-lock.json b/services/package-lock.json index 8cf3cdb67f..8f741db526 100644 --- a/services/package-lock.json +++ b/services/package-lock.json @@ -45,6 +45,7 @@ "nestjs-grpc-reflection": "^0.2.2", "nestjs-spelunker": "^1.3.0", "node-jose": "^2.2.0", + "object-hash": "^3.0.0", "pg": "^8.11.5", "redis": "^4.6.13", "reflect-metadata": "^0.1.14", @@ -69,6 +70,7 @@ "@types/jest": "^29.5.12", "@types/node": "^20.12.4", "@types/node-jose": "^1.1.13", + "@types/object-hash": "^3.0.6", "@types/source-map-support": "^0.5.10", "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.8", @@ -4468,6 +4470,12 @@ "@types/node": "*" } }, + "node_modules/@types/object-hash": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-3.0.6.tgz", + "integrity": "sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==", + "dev": true + }, "node_modules/@types/qs": { "version": "6.9.14", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", @@ -11449,6 +11457,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", diff --git a/services/package.json b/services/package.json index 04d3277cb5..6e1f569355 100644 --- a/services/package.json +++ b/services/package.json @@ -64,6 +64,7 @@ "nestjs-grpc-reflection": "^0.2.2", "nestjs-spelunker": "^1.3.0", "node-jose": "^2.2.0", + "object-hash": "^3.0.0", "pg": "^8.11.5", "redis": "^4.6.13", "reflect-metadata": "^0.1.14", @@ -88,6 +89,7 @@ "@types/jest": "^29.5.12", "@types/node": "^20.12.4", "@types/node-jose": "^1.1.13", + "@types/object-hash": "^3.0.6", "@types/source-map-support": "^0.5.10", "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.8", From f4070fb4951566c7f9f3a5b84b4a817604f129d8 Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Tue, 30 Apr 2024 13:39:47 -0700 Subject: [PATCH 002/103] Add Region dropdown to SRW Intake Page --- .../notification/intake/intake.component.html | 37 +++++++++++++------ .../notification/intake/intake.component.scss | 4 ++ .../intake/intake.component.spec.ts | 11 +++++- .../notification/intake/intake.component.ts | 35 ++++++++++++++++-- .../services/notification/notification.dto.ts | 2 +- .../src/alcs/notification/notification.dto.ts | 4 ++ .../alcs/notification/notification.service.ts | 6 +++ 7 files changed, 82 insertions(+), 17 deletions(-) diff --git a/alcs-frontend/src/app/features/notification/intake/intake.component.html b/alcs-frontend/src/app/features/notification/intake/intake.component.html index f76c2a9342..86e84c971d 100644 --- a/alcs-frontend/src/app/features/notification/intake/intake.component.html +++ b/alcs-frontend/src/app/features/notification/intake/intake.component.html @@ -1,5 +1,5 @@

ALC Intake

-
+
Submitted to ALC
ALC Intake info_outline + >info_outline
@@ -28,20 +28,12 @@

ALC Intake

- warningFailed + warning + Failed
-
-
Local / First Nation Government
- - -
Primary Contact Email
ALC Intake >
+
+
L/FNG Information
+
+
+
+
Local/First Nation Government
+ +
+
+
Region
+ +
+
diff --git a/alcs-frontend/src/app/features/notification/intake/intake.component.scss b/alcs-frontend/src/app/features/notification/intake/intake.component.scss index 56c3f6190b..31c5c7605b 100644 --- a/alcs-frontend/src/app/features/notification/intake/intake.component.scss +++ b/alcs-frontend/src/app/features/notification/intake/intake.component.scss @@ -6,7 +6,11 @@ grid-row-gap: 48px; grid-column-gap: 48px; width: 100%; +} + +.header-row { margin-top: 48px; + margin-bottom: 24px; } .icon { diff --git a/alcs-frontend/src/app/features/notification/intake/intake.component.spec.ts b/alcs-frontend/src/app/features/notification/intake/intake.component.spec.ts index 845bade2d8..25b0966a52 100644 --- a/alcs-frontend/src/app/features/notification/intake/intake.component.spec.ts +++ b/alcs-frontend/src/app/features/notification/intake/intake.component.spec.ts @@ -3,13 +3,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { BehaviorSubject } from 'rxjs'; +import { ApplicationRegionDto } from '../../../services/application/application-code.dto'; import { ApplicationLocalGovernmentService } from '../../../services/application/application-local-government/application-local-government.service'; +import { ApplicationService } from '../../../services/application/application.service'; import { NotificationDetailService } from '../../../services/notification/notification-detail.service'; import { NotificationSubmissionService } from '../../../services/notification/notification-submission/notification-submission.service'; +import { NotificationTimelineService } from '../../../services/notification/notification-timeline/notification-timeline.service'; import { NotificationDto } from '../../../services/notification/notification.dto'; import { ConfirmationDialogService } from '../../../shared/confirmation-dialog/confirmation-dialog.service'; import { IntakeComponent } from './intake.component'; -import { NotificationTimelineService } from '../../../services/notification/notification-timeline/notification-timeline.service'; describe('IntakeComponent', () => { let component: IntakeComponent; @@ -18,14 +20,17 @@ describe('IntakeComponent', () => { let mockLgService: DeepMocked; let mockSubmissionService: DeepMocked; let mockTimelineService: DeepMocked; + let mockApplicationService: DeepMocked; beforeEach(async () => { mockDetailService = createMock(); mockLgService = createMock(); mockSubmissionService = createMock(); mockTimelineService = createMock(); + mockApplicationService = createMock(); mockDetailService.$notification = new BehaviorSubject(undefined); + mockApplicationService.$applicationRegions = new BehaviorSubject([]); await TestBed.configureTestingModule({ imports: [MatSnackBarModule], @@ -50,6 +55,10 @@ describe('IntakeComponent', () => { provide: ConfirmationDialogService, useValue: {}, }, + { + provide: ApplicationService, + useValue: mockApplicationService, + }, ], declarations: [IntakeComponent], schemas: [NO_ERRORS_SCHEMA], diff --git a/alcs-frontend/src/app/features/notification/intake/intake.component.ts b/alcs-frontend/src/app/features/notification/intake/intake.component.ts index 6fc3c99fab..1cdd070cbc 100644 --- a/alcs-frontend/src/app/features/notification/intake/intake.component.ts +++ b/alcs-frontend/src/app/features/notification/intake/intake.component.ts @@ -1,7 +1,9 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import moment from 'moment'; +import { Subject, takeUntil } from 'rxjs'; import { environment } from '../../../../environments/environment'; import { ApplicationLocalGovernmentService } from '../../../services/application/application-local-government/application-local-government.service'; +import { ApplicationService } from '../../../services/application/application.service'; import { NotificationDetailService } from '../../../services/notification/notification-detail.service'; import { NotificationSubmissionService } from '../../../services/notification/notification-submission/notification-submission.service'; import { NotificationTimelineService } from '../../../services/notification/notification-timeline/notification-timeline.service'; @@ -14,13 +16,16 @@ import { ConfirmationDialogService } from '../../../shared/confirmation-dialog/c templateUrl: './intake.component.html', styleUrls: ['./intake.component.scss'], }) -export class IntakeComponent implements OnInit { +export class IntakeComponent implements OnInit, OnDestroy { + $destroy = new Subject(); + dateSubmittedToAlc?: string; notification?: NotificationDto; localGovernments: { label: string; value: string; disabled?: boolean | null }[] = []; contactEmail: string | null = null; responseSent = false; responseDate: number | null = null; + regions: { label: string; value: string }[] = []; constructor( private notificationDetailService: NotificationDetailService, @@ -28,11 +33,12 @@ export class IntakeComponent implements OnInit { private notificationTimelineService: NotificationTimelineService, private localGovernmentService: ApplicationLocalGovernmentService, private confirmationDialogService: ConfirmationDialogService, + private applicationService: ApplicationService, private toastService: ToastService, ) {} ngOnInit(): void { - this.notificationDetailService.$notification.subscribe((notification) => { + this.notificationDetailService.$notification.pipe(takeUntil(this.$destroy)).subscribe((notification) => { if (notification) { this.dateSubmittedToAlc = moment(notification.dateSubmittedToAlc).format(environment.dateFormat); this.notification = notification; @@ -41,9 +47,21 @@ export class IntakeComponent implements OnInit { } }); + this.applicationService.$applicationRegions.pipe(takeUntil(this.$destroy)).subscribe((regions) => { + this.regions = regions.map((region) => ({ + label: region.label, + value: region.code, + })); + }); + this.loadGovernments(); } + ngOnDestroy(): void { + this.$destroy.next(); + this.$destroy.complete(); + } + async updateNotificationDate(field: keyof UpdateNotificationDto, time: number) { const notification = this.notification; if (notification) { @@ -101,6 +119,17 @@ export class IntakeComponent implements OnInit { }); } + async updateRegion($event: string | string[] | null) { + if (this.notification && $event && !Array.isArray($event)) { + const update = await this.notificationDetailService.update(this.notification.fileNumber, { + regionCode: $event, + }); + if (update) { + this.toastService.showSuccessToast('Notification updated'); + } + } + } + async resendResponse() { if (this.notification) { const res = await this.notificationDetailService.resendResponse(this.notification.fileNumber); diff --git a/alcs-frontend/src/app/services/notification/notification.dto.ts b/alcs-frontend/src/app/services/notification/notification.dto.ts index a85c2ba4aa..357309a5eb 100644 --- a/alcs-frontend/src/app/services/notification/notification.dto.ts +++ b/alcs-frontend/src/app/services/notification/notification.dto.ts @@ -1,5 +1,4 @@ import { BaseCodeDto } from '../../shared/dto/base.dto'; -import { LocalGovernmentDto } from '../admin-local-government/admin-local-government.dto'; import { ApplicationRegionDto } from '../application/application-code.dto'; import { ApplicationLocalGovernmentDto } from '../application/application-local-government/application-local-government.dto'; import { CardDto } from '../card/card.dto'; @@ -31,6 +30,7 @@ export interface UpdateNotificationDto { summary?: string; staffObservations?: string; proposalEndDate?: number; + regionCode?: string; } export interface NotificationTypeDto extends BaseCodeDto { diff --git a/services/apps/alcs/src/alcs/notification/notification.dto.ts b/services/apps/alcs/src/alcs/notification/notification.dto.ts index 95ac6dd44a..65322c9dca 100644 --- a/services/apps/alcs/src/alcs/notification/notification.dto.ts +++ b/services/apps/alcs/src/alcs/notification/notification.dto.ts @@ -58,6 +58,10 @@ export class UpdateNotificationDto { @IsOptional() @IsNumber() proposalEndDate?: number; + + @IsOptional() + @IsString() + regionCode?: string; } export class CreateNotificationServiceDto { diff --git a/services/apps/alcs/src/alcs/notification/notification.service.ts b/services/apps/alcs/src/alcs/notification/notification.service.ts index 8e6e88fdc8..a9dabf3155 100644 --- a/services/apps/alcs/src/alcs/notification/notification.service.ts +++ b/services/apps/alcs/src/alcs/notification/notification.service.ts @@ -218,6 +218,12 @@ export class NotificationService { ); } + if (updateDto.regionCode) { + notification.region = await this.codeService.fetchRegion( + updateDto.regionCode, + ); + } + notification.staffObservations = filterUndefined( updateDto.staffObservations, notification.staffObservations, From 104ced9bcc2bfa81d0d05dea4e0cf858a8f8abe5 Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Tue, 30 Apr 2024 14:45:35 -0700 Subject: [PATCH 003/103] Fix File Type on Public Search * Was broken for all types! --- .../public-application-search.service.ts | 2 +- .../public-notice-of-intent-search.service.ts | 18 ++++++++++++++++++ .../public-notification-search.service.ts | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts b/services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts index 990bbdd7cc..2cbf0d6cf4 100644 --- a/services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts +++ b/services/apps/alcs/src/portal/public/search/application/public-application-search.service.ts @@ -386,7 +386,7 @@ export class PublicApplicationSearchService { query = query.andWhere( new Brackets((qb) => qb - .where('appSearch.application_type_code IN (:...typeCodes)', { + .where('app.type_code IN (:...typeCodes)', { typeCodes: searchDto.fileTypes, }) .orWhere( diff --git a/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts index 522df077c9..5c13ac3af5 100644 --- a/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts +++ b/services/apps/alcs/src/portal/public/search/notice-of-intent/public-notice-of-intent-search.service.ts @@ -118,6 +118,10 @@ export class PublicNoticeOfIntentSearchService { this.addFileNumberResults(searchDto, promises); } + if (searchDto.fileTypes) { + this.addFileTypeResults(searchDto, promises); + } + if (searchDto.portalStatusCodes && searchDto.portalStatusCodes.length > 0) { this.addPortalStatusResult(searchDto, promises); } @@ -358,4 +362,18 @@ export class PublicNoticeOfIntentSearchService { promises.push(query.getMany()); } + + private addFileTypeResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + if (searchDto.fileTypes.includes('NOI')) { + const query = this.noiRepository.find({ + select: { + fileNumber: true, + }, + }); + promises.push(query); + } + } } diff --git a/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts b/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts index 9b44b4f3d2..a70ce98b44 100644 --- a/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts +++ b/services/apps/alcs/src/portal/public/search/notification/public-notification-search.service.ts @@ -127,6 +127,10 @@ export class PublicNotificationSearchService { await this.addGovernmentResults(searchDto, promises); } + if (searchDto.fileTypes) { + this.addFileTypeResults(searchDto, promises); + } + if (searchDto.regionCodes && searchDto.regionCodes.length > 0) { this.addRegionResults(searchDto, promises); } @@ -306,4 +310,18 @@ export class PublicNotificationSearchService { promises.push(query.getMany()); } + + private addFileTypeResults( + searchDto: SearchRequestDto, + promises: Promise<{ fileNumber: string }[]>[], + ) { + if (searchDto.fileTypes.includes('SRW')) { + const query = this.notificationRepository.find({ + select: { + fileNumber: true, + }, + }); + promises.push(query); + } + } } From e69e81d5af2621d5ad3a048f29c5988eb057a4a5 Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Tue, 30 Apr 2024 16:24:44 -0700 Subject: [PATCH 004/103] Allow Snowplow via CSP * Track page changes --- alcs-frontend/nginx.conf | 2 +- alcs-frontend/src/app/app.component.ts | 11 +++++++++-- alcs-frontend/src/app/app.module.ts | 2 ++ .../app/services/analytics/analytics.service.ts | 17 +++++++++++++++++ portal-frontend/nginx.conf | 2 +- portal-frontend/src/app/app.component.ts | 1 + 6 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 alcs-frontend/src/app/services/analytics/analytics.service.ts diff --git a/alcs-frontend/nginx.conf b/alcs-frontend/nginx.conf index 6ccc98d57e..b7847867d7 100644 --- a/alcs-frontend/nginx.conf +++ b/alcs-frontend/nginx.conf @@ -19,7 +19,7 @@ http { add_header 'X-XSS-Protection' '1; mode=block'; add_header 'Strict-Transport-Security' 'max-age=31536000; includeSubDomains; preload'; add_header 'Cache-control' 'no-cache'; - add_header 'Content-Security-Policy' "default-src 'self';img-src 'self';style-src 'unsafe-inline' 'self';connect-src $ENABLED_CONNECT_SRC; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; base-uri 'self'; object-src https://nrs.objectstore.gov.bc.ca; frame-src https://alcs-metabase-test.apps.silver.devops.gov.bc.ca https://alcs-metabase-prod.apps.silver.devops.gov.bc.ca https://nrs.objectstore.gov.bc.ca;"; + add_header 'Content-Security-Policy' "default-src 'self'; img-src 'self'; style-src 'unsafe-inline' 'self'; connect-src $ENABLED_CONNECT_SRC; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; base-uri 'self'; object-src https://nrs.objectstore.gov.bc.ca; frame-src https://alcs-metabase-test.apps.silver.devops.gov.bc.ca https://alcs-metabase-prod.apps.silver.devops.gov.bc.ca https://nrs.objectstore.gov.bc.ca; script-src 'self' https://www2.gov.bc.ca"; add_header 'Permissions-Policy' 'camera=(), geolocation=(), microphone=()'; add_header 'Referrer-Policy' 'same-origin'; diff --git a/alcs-frontend/src/app/app.component.ts b/alcs-frontend/src/app/app.component.ts index fd3a584643..295e4b5efb 100644 --- a/alcs-frontend/src/app/app.component.ts +++ b/alcs-frontend/src/app/app.component.ts @@ -1,10 +1,17 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { AnalyticsService } from './services/analytics/analytics.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], }) -export class AppComponent { +export class AppComponent implements OnInit { title = 'alcs-frontend'; + + constructor(private analyticsService: AnalyticsService) {} + + ngOnInit(): void { + this.analyticsService.init(); + } } diff --git a/alcs-frontend/src/app/app.module.ts b/alcs-frontend/src/app/app.module.ts index dda3216465..f6f7d1d014 100644 --- a/alcs-frontend/src/app/app.module.ts +++ b/alcs-frontend/src/app/app.module.ts @@ -12,6 +12,7 @@ import { AuthorizationComponent } from './features/authorization/authorization.c import { NotFoundComponent } from './features/errors/not-found/not-found.component'; import { LoginComponent } from './features/login/login.component'; import { ProvisionComponent } from './features/provision/provision.component'; +import { AnalyticsService } from './services/analytics/analytics.service'; import { AuthInterceptor } from './services/authentication/auth.interceptor'; import { TokenRefreshService } from './services/authentication/token-refresh.service'; import { UnauthorizedInterceptor } from './services/authentication/unauthorized.interceptor'; @@ -37,6 +38,7 @@ import { SharedModule } from './shared/shared.module'; ], imports: [BrowserModule, BrowserAnimationsModule, SharedModule.forRoot(), AppRoutingModule, MomentDateModule], providers: [ + AnalyticsService, { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: UnauthorizedInterceptor, multi: true }, { provide: MAT_DIALOG_DEFAULT_OPTIONS, useValue: { panelClass: 'mat-dialog-override' } }, diff --git a/alcs-frontend/src/app/services/analytics/analytics.service.ts b/alcs-frontend/src/app/services/analytics/analytics.service.ts new file mode 100644 index 0000000000..107e47bf05 --- /dev/null +++ b/alcs-frontend/src/app/services/analytics/analytics.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; + +@Injectable({ + providedIn: 'root', +}) +export class AnalyticsService { + constructor(private router: Router) {} + + init() { + this.router.events.subscribe((event) => { + if (event instanceof NavigationEnd) { + (window as any).snowplow('trackPageView'); + } + }); + } +} diff --git a/portal-frontend/nginx.conf b/portal-frontend/nginx.conf index 8db9efc810..09766604d8 100644 --- a/portal-frontend/nginx.conf +++ b/portal-frontend/nginx.conf @@ -19,7 +19,7 @@ http { add_header 'X-XSS-Protection' '1; mode=block'; add_header 'Strict-Transport-Security' 'max-age=31536000; includeSubDomains; preload'; add_header 'Cache-control' 'no-cache'; - add_header 'Content-Security-Policy' "default-src 'self';img-src 'self';style-src 'unsafe-inline' 'self';connect-src $ENABLED_CONNECT_SRC; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; base-uri 'self'; object-src https://nrs.objectstore.gov.bc.ca; frame-src https://nrs.objectstore.gov.bc.ca;"; + add_header 'Content-Security-Policy' "default-src 'self'; img-src 'self'; style-src 'unsafe-inline' 'self'; connect-src $ENABLED_CONNECT_SRC; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; base-uri 'self'; object-src https://nrs.objectstore.gov.bc.ca; frame-src https://nrs.objectstore.gov.bc.ca; script-src 'self' https://www2.gov.bc.ca;"; add_header 'Permissions-Policy' 'camera=(), geolocation=(), microphone=()'; add_header 'Referrer-Policy' 'same-origin'; diff --git a/portal-frontend/src/app/app.component.ts b/portal-frontend/src/app/app.component.ts index a0fdf755fe..b3aa9e931c 100644 --- a/portal-frontend/src/app/app.component.ts +++ b/portal-frontend/src/app/app.component.ts @@ -17,6 +17,7 @@ export class AppComponent implements OnInit { this.router.events.subscribe((event) => { if (event instanceof NavigationEnd) { this.showHeaderFooter = !event.url.includes('/alcs/'); + (window as any).snowplow('trackPageView'); } }); } From c9ea98c76e17c6d8ba17fc3d8abcf1353c99dc6f Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Tue, 30 Apr 2024 16:53:53 -0700 Subject: [PATCH 005/103] Move snowplow to its own script --- portal-frontend/src/assets/snowplow.js | 30 +++++++++++++++++++++ portal-frontend/src/index.html | 36 +------------------------- 2 files changed, 31 insertions(+), 35 deletions(-) create mode 100644 portal-frontend/src/assets/snowplow.js diff --git a/portal-frontend/src/assets/snowplow.js b/portal-frontend/src/assets/snowplow.js new file mode 100644 index 0000000000..d87b492873 --- /dev/null +++ b/portal-frontend/src/assets/snowplow.js @@ -0,0 +1,30 @@ +// +(function(p, l, o, w, i, n, g) { + if (!p[i]) { + p.GlobalSnowplowNamespace = p.GlobalSnowplowNamespace || []; + p.GlobalSnowplowNamespace.push(i); + p[i] = function() { + (p[i].q = p[i].q || []).push(arguments); + }; + p[i].q = p[i].q || []; + n = l.createElement(o); + g = l.getElementsByTagName(o)[0]; + n.async = 1; + n.src = w; + g.parentNode.insertBefore(n, g); + } +})(window, document, "script", "https://www2.gov.bc.ca/StaticWebResources/static/sp/sp-2-14-0.js", "snowplow"); +var collector = "spm.apps.gov.bc.ca"; +window.snowplow("newTracker", "rt", collector, { + appId: "Snowplow_standalone", + cookieLifetime: 86400 * 548, + platform: "web", + post: true, + forceSecureTracker: true, + contexts: { + webPage: true, performanceTiming: true + } +}); +window.snowplow("enableActivityTracking", 30, 30); // Ping every 30 seconds after 30 seconds +window.snowplow("enableLinkClickTracking"); +// diff --git a/portal-frontend/src/index.html b/portal-frontend/src/index.html index ca34a10771..8f28f22c83 100644 --- a/portal-frontend/src/index.html +++ b/portal-frontend/src/index.html @@ -9,41 +9,7 @@ - - + From 59b01cf40dd0c16c3feeb1cd4d097d837093dece Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Wed, 1 May 2024 10:40:35 -0700 Subject: [PATCH 006/103] More Snowplow * Add script hash to enable it to load inline * Remove analytics service from ALCS, we are only doing tracking on portal --- alcs-frontend/src/app/app.component.ts | 11 +++-------- alcs-frontend/src/app/app.module.ts | 2 -- .../app/services/analytics/analytics.service.ts | 17 ----------------- portal-frontend/nginx.conf | 2 +- 4 files changed, 4 insertions(+), 28 deletions(-) delete mode 100644 alcs-frontend/src/app/services/analytics/analytics.service.ts diff --git a/alcs-frontend/src/app/app.component.ts b/alcs-frontend/src/app/app.component.ts index 295e4b5efb..b9e3e83ade 100644 --- a/alcs-frontend/src/app/app.component.ts +++ b/alcs-frontend/src/app/app.component.ts @@ -1,17 +1,12 @@ -import { Component, OnInit } from '@angular/core'; -import { AnalyticsService } from './services/analytics/analytics.service'; +import { Component } from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], }) -export class AppComponent implements OnInit { +export class AppComponent { title = 'alcs-frontend'; - constructor(private analyticsService: AnalyticsService) {} - - ngOnInit(): void { - this.analyticsService.init(); - } + constructor() {} } diff --git a/alcs-frontend/src/app/app.module.ts b/alcs-frontend/src/app/app.module.ts index f6f7d1d014..dda3216465 100644 --- a/alcs-frontend/src/app/app.module.ts +++ b/alcs-frontend/src/app/app.module.ts @@ -12,7 +12,6 @@ import { AuthorizationComponent } from './features/authorization/authorization.c import { NotFoundComponent } from './features/errors/not-found/not-found.component'; import { LoginComponent } from './features/login/login.component'; import { ProvisionComponent } from './features/provision/provision.component'; -import { AnalyticsService } from './services/analytics/analytics.service'; import { AuthInterceptor } from './services/authentication/auth.interceptor'; import { TokenRefreshService } from './services/authentication/token-refresh.service'; import { UnauthorizedInterceptor } from './services/authentication/unauthorized.interceptor'; @@ -38,7 +37,6 @@ import { SharedModule } from './shared/shared.module'; ], imports: [BrowserModule, BrowserAnimationsModule, SharedModule.forRoot(), AppRoutingModule, MomentDateModule], providers: [ - AnalyticsService, { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: UnauthorizedInterceptor, multi: true }, { provide: MAT_DIALOG_DEFAULT_OPTIONS, useValue: { panelClass: 'mat-dialog-override' } }, diff --git a/alcs-frontend/src/app/services/analytics/analytics.service.ts b/alcs-frontend/src/app/services/analytics/analytics.service.ts deleted file mode 100644 index 107e47bf05..0000000000 --- a/alcs-frontend/src/app/services/analytics/analytics.service.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; - -@Injectable({ - providedIn: 'root', -}) -export class AnalyticsService { - constructor(private router: Router) {} - - init() { - this.router.events.subscribe((event) => { - if (event instanceof NavigationEnd) { - (window as any).snowplow('trackPageView'); - } - }); - } -} diff --git a/portal-frontend/nginx.conf b/portal-frontend/nginx.conf index 09766604d8..24ca3ba89c 100644 --- a/portal-frontend/nginx.conf +++ b/portal-frontend/nginx.conf @@ -19,7 +19,7 @@ http { add_header 'X-XSS-Protection' '1; mode=block'; add_header 'Strict-Transport-Security' 'max-age=31536000; includeSubDomains; preload'; add_header 'Cache-control' 'no-cache'; - add_header 'Content-Security-Policy' "default-src 'self'; img-src 'self'; style-src 'unsafe-inline' 'self'; connect-src $ENABLED_CONNECT_SRC; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; base-uri 'self'; object-src https://nrs.objectstore.gov.bc.ca; frame-src https://nrs.objectstore.gov.bc.ca; script-src 'self' https://www2.gov.bc.ca;"; + add_header 'Content-Security-Policy' "default-src 'self'; img-src 'self'; style-src 'unsafe-inline' 'self'; connect-src $ENABLED_CONNECT_SRC; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; base-uri 'self'; object-src https://nrs.objectstore.gov.bc.ca; frame-src https://nrs.objectstore.gov.bc.ca; script-src 'self' https://www2.gov.bc.ca sha256-evje5KswYvntfuZqc5jmvUSANhIntI7Or6vVnjxGGQE=;"; add_header 'Permissions-Policy' 'camera=(), geolocation=(), microphone=()'; add_header 'Referrer-Policy' 'same-origin'; From 00088fcdb59f98161370ff183074fd19e03aa903 Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Wed, 1 May 2024 11:40:56 -0700 Subject: [PATCH 007/103] Add connect-src for snowplower, fix main docker --- alcs-frontend/Dockerfile | 6 ++++-- portal-frontend/Dockerfile | 2 ++ portal-frontend/nginx.conf | 2 +- services/config/default.json | 12 ++++++++---- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/alcs-frontend/Dockerfile b/alcs-frontend/Dockerfile index 8b3552cf30..0e4e9569d5 100644 --- a/alcs-frontend/Dockerfile +++ b/alcs-frontend/Dockerfile @@ -5,14 +5,16 @@ FROM node:20-alpine AS build WORKDIR /app # Copy package.json file -COPY package.json . +COPY package.json package-lock.json ./ # Install dependencies -RUN npm install +RUN npm ci # Copy the source code to the /app directory COPY . . +ENV NODE_OPTIONS="--max-old-space-size=2048" + # Build the application RUN npm run build -- --output-path=dist --output-hashing=all diff --git a/portal-frontend/Dockerfile b/portal-frontend/Dockerfile index 8b3f351595..212720b0e3 100644 --- a/portal-frontend/Dockerfile +++ b/portal-frontend/Dockerfile @@ -13,6 +13,8 @@ RUN npm ci # Copy the source code to the /app directory COPY . . +ENV NODE_OPTIONS="--max-old-space-size=2048" + # Build the application RUN npm run build -- --output-path=dist --output-hashing=all diff --git a/portal-frontend/nginx.conf b/portal-frontend/nginx.conf index 24ca3ba89c..4f9a35f8a8 100644 --- a/portal-frontend/nginx.conf +++ b/portal-frontend/nginx.conf @@ -19,7 +19,7 @@ http { add_header 'X-XSS-Protection' '1; mode=block'; add_header 'Strict-Transport-Security' 'max-age=31536000; includeSubDomains; preload'; add_header 'Cache-control' 'no-cache'; - add_header 'Content-Security-Policy' "default-src 'self'; img-src 'self'; style-src 'unsafe-inline' 'self'; connect-src $ENABLED_CONNECT_SRC; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; base-uri 'self'; object-src https://nrs.objectstore.gov.bc.ca; frame-src https://nrs.objectstore.gov.bc.ca; script-src 'self' https://www2.gov.bc.ca sha256-evje5KswYvntfuZqc5jmvUSANhIntI7Or6vVnjxGGQE=;"; + add_header 'Content-Security-Policy' "default-src 'self'; img-src 'self'; style-src 'unsafe-inline' 'self'; connect-src $ENABLED_CONNECT_SRC https://spm.apps.gov.bc.ca; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; base-uri 'self'; object-src https://nrs.objectstore.gov.bc.ca; frame-src https://nrs.objectstore.gov.bc.ca; script-src 'self' https://www2.gov.bc.ca 'sha256-evje5KswYvntfuZqc5jmvUSANhIntI7Or6vVnjxGGQE=';"; add_header 'Permissions-Policy' 'camera=(), geolocation=(), microphone=()'; add_header 'Referrer-Policy' 'same-origin'; diff --git a/services/config/default.json b/services/config/default.json index f4b411c42c..6cfa5e4351 100644 --- a/services/config/default.json +++ b/services/config/default.json @@ -32,7 +32,9 @@ "AUTH_SERVER": "test.loginproxy.gov.bc.ca", "AUTH_SERVER_URL": "https://test.loginproxy.gov.bc.ca/auth", "AUTH_TOKEN_URL": "https://test.loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/token", - "SCOPES": ["openid"], + "SCOPES": [ + "openid" + ], "REALM": "standard" }, "SITEMINDER": { @@ -61,12 +63,14 @@ "MAX_FILE_SIZE": 104857600 }, "REDIS": { - "HOST": "localhost", + "HOST": "redis", "PORT": "6379", - "PASSWORD": "" + "PASSWORD": "redis" }, "EMAIL": { - "DEFAULT_ADMINS": [""] + "DEFAULT_ADMINS": [ + "" + ] }, "GRPC": { "BIND_URL": "localhost:50057", From 8575bac32f9b0a5d9723b3b6fb79f2fea634f578 Mon Sep 17 00:00:00 2001 From: Mekhti Date: Thu, 2 May 2024 11:15:57 -0700 Subject: [PATCH 008/103] wip status reset --- .../lfng_status_adjustment/__init__.py | 1 + .../lfng_status_adjustment.py | 231 ++++++++++++++++++ .../sql/oats_latest_lfng_status.sql | 78 ++++++ .../sql/oats_latest_lfng_status_back.sql | 38 +++ .../sql/oats_latest_lfng_status_count.sql | 32 +++ .../applications/post_launch/__init__.py | 1 + .../menu/post_launch_commands/applications.py | 18 ++ bin/migrate-oats-data/migrate.py | 17 +- 8 files changed, 401 insertions(+), 15 deletions(-) create mode 100644 bin/migrate-oats-data/applications/lfng_status_adjustment/__init__.py create mode 100644 bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py create mode 100644 bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status.sql create mode 100644 bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_back.sql create mode 100644 bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql diff --git a/bin/migrate-oats-data/applications/lfng_status_adjustment/__init__.py b/bin/migrate-oats-data/applications/lfng_status_adjustment/__init__.py new file mode 100644 index 0000000000..50612f8947 --- /dev/null +++ b/bin/migrate-oats-data/applications/lfng_status_adjustment/__init__.py @@ -0,0 +1 @@ +from .lfng_status_adjustment import readjust_lfng_statuses \ No newline at end of file diff --git a/bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py b/bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py new file mode 100644 index 0000000000..a1781e2752 --- /dev/null +++ b/bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py @@ -0,0 +1,231 @@ +from common import ( + BATCH_UPLOAD_SIZE, + setup_and_get_logger, + add_timezone_and_keep_date_part, +) +from db import inject_conn_pool +from psycopg2.extras import RealDictCursor, execute_batch + +etl_name = "readjust_lfng_statuses" +logger = setup_and_get_logger(etl_name) + + +@inject_conn_pool +def readjust_lfng_statuses(conn=None, batch_size=BATCH_UPLOAD_SIZE): + """ + TODO copy description from JIRA + + Args: + conn (psycopg2.extensions.connection): PostgreSQL database connection. Provided by the decorator. + batch_size (int): The number of items to process at once. Defaults to BATCH_UPLOAD_SIZE. + """ + + logger.info(f"Start {etl_name}") + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + with open( + "applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql", + "r", + encoding="utf-8", + ) as sql_file: + count_query = sql_file.read() + cursor.execute(count_query) + count_total = dict(cursor.fetchone())["count"] + logger.info(f"Total applications to process: {count_total}") + + failed_inserts = 0 + successful_updates_count = 0 + last_entry_id = 0 + with open( + "applications/lfng_status_adjustment/sql/oats_latest_lfng_status.sql", + "r", + encoding="utf-8", + ) as sql_file: + query = sql_file.read() + while True: + cursor.execute( + f""" + {query} + AND lols.alr_application_id > {last_entry_id} + ORDER BY lols.alr_application_id; + """ + ) + + rows = cursor.fetchmany(batch_size) + + if not rows: + break + try: + processed_applications_count = _process_statuses_for_update( + conn, batch_size, cursor, rows + ) + + successful_updates_count = ( + successful_updates_count + processed_applications_count + ) + last_entry_id = dict(rows[-1])["alr_application_id"] + + logger.debug( + f"retrieved/inserted items count: {0}; total successfully updated entries so far {successful_updates_count}; last updated alr_application_id: {last_entry_id}" + ) + except Exception as err: + logger.exception(err) + conn.rollback() + failed_inserts = count_total - successful_updates_count + last_entry_id = last_entry_id + 1 + + logger.info( + f"Finished {etl_name}: total amount of successful inserts {successful_updates_count}, total failed inserts {failed_inserts}" + ) + + +def _process_statuses_for_update(conn, batch_size, cursor, rows): + + update_statements = [] + grouped_by_fn = [] + processed_applications_count = 0 + + for row in rows: + if not grouped_by_fn or ( + grouped_by_fn + and grouped_by_fn[0]["alr_application_id"] == row["alr_application_id"] + ): + grouped_by_fn.append(row) + else: + _prepare_update_statement(grouped_by_fn, update_statements) + + processed_applications_count += 1 + grouped_by_fn = [] + grouped_by_fn.append(row) + # TODO: delete this + # for update_statement in update_statements: + # cursor.execute(update_statement) + + # conn.commit() + + combined_statements = "; ".join(update_statements) + print('combined_statements: ', combined_statements) + #TODO uncomment this + # cursor.execute(combined_statements) + # conn.commit() + return processed_applications_count + + +def _prepare_update_statement(statuses, update_statements): + """ + 1. get OATS status + 2. map it to ALCS status + 3. check if statuses have the status from step 2 + 4. get the weight of status from step 2 + 5. check if status from step 2 is of max weight + 6. if yes then proceed otherwise for all statuses that are higher weight rest effective_date and email_sent + """ + oats_status = statuses[0]["accomplishment_code"] + alcs_status = _map_oats_accomplishment_code_to_alcs_status_code(oats_status) + + print( + "oats_status ", + oats_status, + " ", + alcs_status, + ) + + if alcs_status == "RFFG": + update_statements.append( + _compile_update_statement(statuses, oats_status, "RFFG", []) + ) + # check if RFFG exists in statuses and if yes do nothing, otherwise set to specific value + # status = _get_status(statuses, "RFFG") + # if status: + # print("no action needed") + # else: + # print("Set RFFG value") + # date = add_timezone_and_keep_date_part(status["completion_date"]) + # update_statements.append( + # f""" + # UPDATE alcs.application_submission_to_submission_status + # SET effective_date = {date}, + # email_sent_date = '0001-01-01 06:00:00.000 -0800' + # WHERE status_type_code = 'RFFG' AND submission_uuid = {status["uuid"]}; + # """ + # ) + elif alcs_status == "REVG": + update_statements.append( + _compile_update_statement(statuses, oats_status, "REVG", ["RFFG"]) + ) + elif alcs_status == "SUBG": + update_statements.append( + _compile_update_statement(statuses, oats_status, "SUBG", ["RFFG", "REVG"]) + ) + elif alcs_status == "INCM": + update_statements.append( + _compile_update_statement( + statuses, oats_status, "INCM", ["RFFG", "REVG", "SUBG", "WRNG"] + ) + ) + elif alcs_status == "WRNG": + update_statements.append( + _compile_update_statement( + statuses, oats_status, "WRNG", ["RFFG", "REVG", "SUBG", "INCM"] + ) + ) + + +def _compile_update_statement( + statuses, oats_target_status, alcs_target_status, alcs_reset_statuses_codes=None +): + oats_status = _get_oats_status(statuses, oats_target_status) + alcs_status = _get_alcs_status(statuses, oats_target_status) + print("status retrieved ", oats_status, alcs_status, statuses) + + str_statuses_to_reset = "" + reset_statuses_query = "" + + if alcs_reset_statuses_codes: + str_statuses_to_reset = "', '".join(alcs_reset_statuses_codes) + reset_statuses_query = f""" + UPDATE alcs.application_submission_to_submission_status + SET effective_date = NULL, + email_sent_date = NULL + WHERE status_type_code in ('{str_statuses_to_reset}') AND submission_uuid = '{oats_status["uuid"]}'; + """ + + if alcs_status: + print(f"reset {str_statuses_to_reset}") + return reset_statuses_query + else: + print(f"{str_statuses_to_reset} and set {alcs_target_status}") + date = add_timezone_and_keep_date_part(oats_status["completion_date"]) + return f""" + {reset_statuses_query} + + UPDATE alcs.application_submission_to_submission_status + SET effective_date = '{date}', + email_sent_date = '0001-01-01 06:00:00.000 -0800' + WHERE status_type_code = '{alcs_target_status}' AND submission_uuid = '{oats_status["uuid"]}'; + """ + + +def _get_oats_status(statuses, code): + for status in statuses: + if status["accomplishment_code"] == code: + return status + return None + +def _get_alcs_status(statuses, code): + for status in statuses: + if status["status_type_code"] == code: + return status + return None + + +def _map_oats_accomplishment_code_to_alcs_status_code(code): + if code == "LRF": + return "RFFG" + if code == "SLG": + return "SUBG" + if code == "WLG": + return "WRNG" + if code == "ULG": + return "REVG" + if code == "LGI": + return "INCM" diff --git a/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status.sql b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status.sql new file mode 100644 index 0000000000..fb05be4ad7 --- /dev/null +++ b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status.sql @@ -0,0 +1,78 @@ +WITH when_updated_grouped AS ( + SELECT + a.when_updated, + a.alr_application_id, + a.accomplishment_code, + COUNT(*) OVER (PARTITION BY a.when_updated, a.alr_application_id) as cnt + FROM + oats.oats_accomplishments a + WHERE a.when_updated IS NOT NULL +), when_updated_with_status AS ( + SELECT + when_updated, + alr_application_id, + accomplishment_code, + cnt + FROM + when_updated_grouped + WHERE + cnt > 1 +), completion_grouped AS ( + SELECT + wuws.when_updated, + wuws.alr_application_id, + wuws.accomplishment_code, + COUNT(*) OVER (PARTITION BY oa.completion_date, wuws.alr_application_id) as cnt + FROM + when_updated_with_status wuws + JOIN oats.oats_accomplishments oa ON oa.alr_application_id = wuws.alr_application_id AND oa.accomplishment_code = wuws.accomplishment_code + WHERE oa.completion_date IS NOT NULL +), completion_with_status AS ( + SELECT + when_updated, + alr_application_id, + accomplishment_code, + cnt + FROM + completion_grouped + WHERE + cnt > 1 +), +alr_applications_to_exclude AS ( SELECT alr_application_id FROM completion_with_status) +, submitted_under_review AS ( + SELECT astss.submission_uuid AS initial_sub_uuid FROM alcs.application_submission_to_submission_status astss + JOIN alcs.application_submission as2 ON as2."uuid" = astss.submission_uuid AND as2.audit_created_by='oats_etl' AND as2.audit_updated_by IS NULL + WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('SUBG', 'REVG') + GROUP BY astss.submission_uuid +) +, returned_incomplete_refused AS ( + SELECT as2.file_number, string_agg(astss.status_type_code, ', ') + FROM submitted_under_review sur + JOIN alcs.application_submission_to_submission_status astss ON astss.submission_uuid = sur.initial_sub_uuid + JOIN alcs.application_submission as2 ON as2.uuid = astss.submission_uuid + WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('WRNG', 'INCM', 'RFFG', 'SUBG', 'REVG') + GROUP BY as2.file_number +) +, oats_accomplishments AS ( + SELECT rir.*, oaac.* FROM oats.oats_accomplishments oaac + JOIN returned_incomplete_refused AS rir ON rir.file_number::bigint = oaac.alr_application_id + WHERE oaac.accomplishment_code IN ('LRF', 'SLG', 'WLG', 'ULG', 'LGI') +) +-- ranked_statuses will select the latest status based on max completion_date, then when_updated, then when_created for all records per file_number +, ranked_statuses AS ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY file_number ORDER BY COALESCE(completion_date, '0001-01-01') DESC, COALESCE(when_updated, '0001-01-01') DESC, COALESCE(when_created, '0001-01-01') DESC) AS rn + FROM oats_accomplishments +) +, latest_oats_lfng_status AS ( + SELECT alr_application_id, accomplishment_code, completion_date, when_created, when_updated, revision_count FROM ranked_statuses + WHERE rn = 1 + ORDER BY file_number::bigint +) +SELECT as2.uuid, lols.alr_application_id, accomplishment_code, lols.completion_date, when_created, when_updated, astss.status_type_code, astss.effective_date, asst.weight +FROM alcs.application_submission_to_submission_status astss +JOIN alcs.application_submission as2 ON as2.uuid = astss.submission_uuid +JOIN alcs.application_submission_status_type asst ON asst.code = astss.status_type_code +JOIN latest_oats_lfng_status lols ON lols.alr_application_id = as2.file_number::bigint +LEFT JOIN alr_applications_to_exclude aate ON aate.alr_application_id = lols.alr_application_id +WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('WRNG', 'INCM', 'RFFG', 'SUBG', 'REVG') AND aate.alr_application_id IS NULL \ No newline at end of file diff --git a/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_back.sql b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_back.sql new file mode 100644 index 0000000000..5f2ca65b49 --- /dev/null +++ b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_back.sql @@ -0,0 +1,38 @@ + +WITH submitted_under_review AS ( + SELECT astss.submission_uuid AS initial_sub_uuid FROM alcs.application_submission_to_submission_status astss + JOIN alcs.application_submission as2 ON as2."uuid" = astss.submission_uuid AND as2.audit_created_by='oats_etl' AND as2.audit_updated_by IS NULL + WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('SUBG', 'REVG') + GROUP BY astss.submission_uuid +) +, returned_incomplete_refused AS ( + SELECT as2.file_number, string_agg(astss.status_type_code, ', ') + FROM submitted_under_review sur + JOIN alcs.application_submission_to_submission_status astss ON astss.submission_uuid = sur.initial_sub_uuid + JOIN alcs.application_submission as2 ON as2.uuid = astss.submission_uuid + WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('WRNG', 'INCM', 'RFFG', 'SUBG', 'REVG') + GROUP BY as2.file_number +) +, oats_accomplishments AS ( + SELECT rir.*, oaac.* FROM oats.oats_accomplishments oaac + JOIN returned_incomplete_refused AS rir ON rir.file_number::bigint = oaac.alr_application_id + WHERE oaac.accomplishment_code IN ('LRF', 'SLG', 'WLG', 'ULG', 'LGI') +) +-- ranked_statuses will select the latest status based on max completion_date, then when_updated, then when_created for all records per file_number +, ranked_statuses AS ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY file_number ORDER BY COALESCE(completion_date, '0001-01-01') DESC, COALESCE(when_updated, '0001-01-01') DESC, COALESCE(when_created, '0001-01-01') DESC) AS rn + FROM oats_accomplishments +) +, latest_oats_lfng_status AS ( + SELECT alr_application_id, accomplishment_code, completion_date, when_created, when_updated, revision_count FROM ranked_statuses + WHERE rn = 1 + ORDER BY file_number::bigint +) +SELECT as2.uuid, lols.alr_application_id, accomplishment_code, lols.completion_date, when_created, when_updated, astss.status_type_code, astss.effective_date, asst.weight +FROM alcs.application_submission_to_submission_status astss +JOIN alcs.application_submission as2 ON as2.uuid = astss.submission_uuid +JOIN alcs.application_submission_status_type asst ON asst.code = astss.status_type_code +JOIN latest_oats_lfng_status lols ON lols.alr_application_id = as2.file_number::bigint +WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('WRNG', 'INCM', 'RFFG', 'SUBG', 'REVG') +ORDER BY lols.alr_application_id \ No newline at end of file diff --git a/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql new file mode 100644 index 0000000000..d004f511ff --- /dev/null +++ b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql @@ -0,0 +1,32 @@ +-- select latest OATS government status +WITH submitted_under_review AS ( + SELECT astss.submission_uuid AS initial_sub_uuid FROM alcs.application_submission_to_submission_status astss + JOIN alcs.application_submission as2 ON as2."uuid" = astss.submission_uuid AND as2.audit_created_by='oats_etl' AND as2.audit_updated_by IS NULL + WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('SUBG', 'REVG') + GROUP BY astss.submission_uuid +) +, returned_incomplete_refused AS ( + SELECT as2.file_number, string_agg(astss.status_type_code, ', ') + FROM submitted_under_review sur + JOIN alcs.application_submission_to_submission_status astss ON astss.submission_uuid = sur.initial_sub_uuid + JOIN alcs.application_submission as2 ON as2.uuid = astss.submission_uuid + WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('WRNG', 'INCM', 'RFFG', 'SUBG', 'REVG') + GROUP BY as2.file_number +) +, oats_accomplishments AS ( + SELECT rir.*, oaac.* FROM oats.oats_accomplishments oaac + JOIN returned_incomplete_refused AS rir ON rir.file_number::bigint = oaac.alr_application_id + WHERE oaac.accomplishment_code IN ('LRF', 'SLG', 'WLG', 'ULG', 'LGI') +) +-- ranked_statuses will select the latest status based on max between when_created or when_updated for all records per file_number +, ranked_statuses AS ( + SELECT *, GREATEST(COALESCE(when_created, '0001-01-01'), COALESCE(when_updated, '0001-01-01')) AS max_date, ROW_NUMBER() OVER (PARTITION BY file_number ORDER BY GREATEST(COALESCE(when_created, '0001-01-01'), COALESCE(when_updated, '0001-01-01')) DESC) AS rn + FROM oats_accomplishments +) +, latest_oats_lfng_status AS ( + SELECT * FROM ranked_statuses + WHERE + rn = 1 + ORDER BY file_number::bigint +) +SELECT count(*) FROM latest_oats_lfng_status diff --git a/bin/migrate-oats-data/applications/post_launch/__init__.py b/bin/migrate-oats-data/applications/post_launch/__init__.py index 006e44acec..53fac83984 100644 --- a/bin/migrate-oats-data/applications/post_launch/__init__.py +++ b/bin/migrate-oats-data/applications/post_launch/__init__.py @@ -1,2 +1,3 @@ from .migrate_application import * from .clean import * +from ..lfng_status_adjustment import * diff --git a/bin/migrate-oats-data/menu/post_launch_commands/applications.py b/bin/migrate-oats-data/menu/post_launch_commands/applications.py index ef24ab2b59..9910a92199 100644 --- a/bin/migrate-oats-data/menu/post_launch_commands/applications.py +++ b/bin/migrate-oats-data/menu/post_launch_commands/applications.py @@ -1,6 +1,7 @@ from applications.post_launch import ( process_application_etl, clean_alcs_applications, + readjust_lfng_statuses, ) @@ -23,3 +24,20 @@ def application_clean(console): console.log("Beginning ALCS application clean") with console.status("[bold green]Cleaning ALCS Applications...\n") as status: clean_alcs_applications() + + +def application_local_government_status_reset(console, args): + console.log( + "Beginning OATS -> ALCS application local government status reset process" + ) + with console.status( + "[bold green]application import (Application and application related table update in ALCS)...\n" + ) as status: + if args.batch_size: + import_batch_size = args.batch_size + + console.log( + f"Processing applications local government status reset in batch size = {import_batch_size}" + ) + + readjust_lfng_statuses(batch_size=import_batch_size) diff --git a/bin/migrate-oats-data/migrate.py b/bin/migrate-oats-data/migrate.py index 4ddb57a0a1..3748f5d361 100644 --- a/bin/migrate-oats-data/migrate.py +++ b/bin/migrate-oats-data/migrate.py @@ -6,24 +6,17 @@ from menu import setup_menu_args_parser from menu.post_launch_commands import ( - import_all, - clean_all, - application_import, - application_clean, notice_of_intent_import, notice_of_intent_clean, - srw_import, - srw_clean, document_import, document_clean, planning_review_clean, planning_review_import, inquiry_import, inquiry_clean, - document_import_pr_inq, - document_clean_pr_inq, import_all_pr_inq, clean_all_pr_inq, + application_local_government_status_reset, ) from menu import start_obfuscation from db import connection_pool @@ -51,13 +44,7 @@ case "noi-clean": notice_of_intent_clean(console) case "application-import": - application_import(console, args) - case "application-clean": - application_clean(console) - case "srw-import": - srw_import(console, args) - case "srw-clean": - srw_clean(console) + application_local_government_status_reset(console, args) case "document-import": document_import(console, args) case "document-clean": From 2afac1b86ffb9aa5ce2eb95fad603b75ff4a0a01 Mon Sep 17 00:00:00 2001 From: Mekhti Date: Thu, 2 May 2024 15:33:32 -0700 Subject: [PATCH 009/103] reset lfng statuses for applications --- .../lfng_status_adjustment.py | 55 ++++--------------- 1 file changed, 10 insertions(+), 45 deletions(-) diff --git a/bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py b/bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py index a1781e2752..7b42a538a7 100644 --- a/bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py +++ b/bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py @@ -4,7 +4,7 @@ add_timezone_and_keep_date_part, ) from db import inject_conn_pool -from psycopg2.extras import RealDictCursor, execute_batch +from psycopg2.extras import RealDictCursor etl_name = "readjust_lfng_statuses" logger = setup_and_get_logger(etl_name) @@ -64,8 +64,8 @@ def readjust_lfng_statuses(conn=None, batch_size=BATCH_UPLOAD_SIZE): ) last_entry_id = dict(rows[-1])["alr_application_id"] - logger.debug( - f"retrieved/inserted items count: {0}; total successfully updated entries so far {successful_updates_count}; last updated alr_application_id: {last_entry_id}" + logger.info( + f"retrieved/updated items count: {processed_applications_count}; total successfully updated entries so far {successful_updates_count}; last updated alr_application_id: {last_entry_id}" ) except Exception as err: logger.exception(err) @@ -96,58 +96,23 @@ def _process_statuses_for_update(conn, batch_size, cursor, rows): processed_applications_count += 1 grouped_by_fn = [] grouped_by_fn.append(row) - # TODO: delete this - # for update_statement in update_statements: - # cursor.execute(update_statement) - - # conn.commit() combined_statements = "; ".join(update_statements) - print('combined_statements: ', combined_statements) - #TODO uncomment this - # cursor.execute(combined_statements) - # conn.commit() + # this is useful for debugging + # logger.debug("combined_statements: %s", combined_statements) + cursor.execute(combined_statements) + conn.commit() return processed_applications_count def _prepare_update_statement(statuses, update_statements): - """ - 1. get OATS status - 2. map it to ALCS status - 3. check if statuses have the status from step 2 - 4. get the weight of status from step 2 - 5. check if status from step 2 is of max weight - 6. if yes then proceed otherwise for all statuses that are higher weight rest effective_date and email_sent - """ oats_status = statuses[0]["accomplishment_code"] alcs_status = _map_oats_accomplishment_code_to_alcs_status_code(oats_status) - print( - "oats_status ", - oats_status, - " ", - alcs_status, - ) - if alcs_status == "RFFG": update_statements.append( _compile_update_statement(statuses, oats_status, "RFFG", []) ) - # check if RFFG exists in statuses and if yes do nothing, otherwise set to specific value - # status = _get_status(statuses, "RFFG") - # if status: - # print("no action needed") - # else: - # print("Set RFFG value") - # date = add_timezone_and_keep_date_part(status["completion_date"]) - # update_statements.append( - # f""" - # UPDATE alcs.application_submission_to_submission_status - # SET effective_date = {date}, - # email_sent_date = '0001-01-01 06:00:00.000 -0800' - # WHERE status_type_code = 'RFFG' AND submission_uuid = {status["uuid"]}; - # """ - # ) elif alcs_status == "REVG": update_statements.append( _compile_update_statement(statuses, oats_status, "REVG", ["RFFG"]) @@ -175,7 +140,6 @@ def _compile_update_statement( ): oats_status = _get_oats_status(statuses, oats_target_status) alcs_status = _get_alcs_status(statuses, oats_target_status) - print("status retrieved ", oats_status, alcs_status, statuses) str_statuses_to_reset = "" reset_statuses_query = "" @@ -190,10 +154,10 @@ def _compile_update_statement( """ if alcs_status: - print(f"reset {str_statuses_to_reset}") + logger.debug(f"reset {str_statuses_to_reset}") return reset_statuses_query else: - print(f"{str_statuses_to_reset} and set {alcs_target_status}") + logger.debug(f"{str_statuses_to_reset} and set {alcs_target_status}") date = add_timezone_and_keep_date_part(oats_status["completion_date"]) return f""" {reset_statuses_query} @@ -211,6 +175,7 @@ def _get_oats_status(statuses, code): return status return None + def _get_alcs_status(statuses, code): for status in statuses: if status["status_type_code"] == code: From 8c1df848060581734ed9ca463d48a17a8e98fc42 Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Fri, 12 Apr 2024 12:34:42 -0700 Subject: [PATCH 010/103] Give login test a better name --- e2e/tests/portal/login.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/e2e/tests/portal/login.spec.ts b/e2e/tests/portal/login.spec.ts index 9689e5c839..61321d8315 100644 --- a/e2e/tests/portal/login.spec.ts +++ b/e2e/tests/portal/login.spec.ts @@ -2,8 +2,6 @@ import { test, expect, UserPrefix } from './fixtures'; test.use({ userPrefix: UserPrefix.BceidBasic }); -test('test', async ({ inboxLoggedIn }) => { - await expect( - inboxLoggedIn.getByRole('heading', { name: 'Portal Inbox' }) - ).toBeVisible(); +test('should redirect to inbox after login', async ({ inboxLoggedIn }) => { + await expect(inboxLoggedIn.getByRole('heading', { name: 'Portal Inbox' })).toBeVisible(); }); From 776951eac38fe4c95a6b81b0b4b7baad6967c882 Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Fri, 12 Apr 2024 12:37:19 -0700 Subject: [PATCH 011/103] Create reliable TUR creation steps --- e2e/tests/portal/tur.spec.ts | 151 +++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 e2e/tests/portal/tur.spec.ts diff --git a/e2e/tests/portal/tur.spec.ts b/e2e/tests/portal/tur.spec.ts new file mode 100644 index 0000000000..f92c0f9088 --- /dev/null +++ b/e2e/tests/portal/tur.spec.ts @@ -0,0 +1,151 @@ +import { test, expect, UserPrefix } from './fixtures'; + +test.use({ userPrefix: UserPrefix.BceidLg }); + +test('TUR', async ({ inboxLoggedIn }) => { + // Create TUR app + await inboxLoggedIn.getByRole('button', { name: '+ Create New' }).click(); + await inboxLoggedIn.getByText('Application', { exact: true }).click(); + await inboxLoggedIn.getByRole('button', { name: 'Next' }).click(); + await inboxLoggedIn.getByText('Transportation, Utility, or Recreational Trail Uses within the ALR').click(); + await inboxLoggedIn.getByRole('button', { name: 'create' }).click(); + await inboxLoggedIn.getByText('Parcel Details', { exact: true }).click(); // Ensure parcels page + + // Step 1a: Parcels + await inboxLoggedIn.getByRole('button', { name: 'Fee Simple' }).click(); + await inboxLoggedIn.getByPlaceholder('Type legal description').fill('Parcel description'); + await inboxLoggedIn.getByPlaceholder('Type parcel size').fill('1'); + await inboxLoggedIn.getByPlaceholder('Type PID').fill('111-111-111'); + await inboxLoggedIn.getByRole('button', { name: 'Open calendar' }).click(); + await inboxLoggedIn.getByText('2014').click(); + await inboxLoggedIn.getByText('Apr').click(); + await inboxLoggedIn.getByText('23').click(); + await inboxLoggedIn.getByText('Yes').click(); + await inboxLoggedIn.getByPlaceholder('Type Address').fill('123 Street Rd'); + + // Upload + const titleFileChooserPromise = inboxLoggedIn.waitForEvent('filechooser'); + await inboxLoggedIn.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); + const titleFileChooser = await titleFileChooserPromise; + titleFileChooser.setFiles('data/temp.txt'); + + // Step 1b: Parcel Owners + await inboxLoggedIn.getByRole('button', { name: 'Add new owner' }).click(); + await inboxLoggedIn.getByRole('button', { name: 'Individual' }).click(); + await inboxLoggedIn.getByPlaceholder('Enter First Name').fill('1'); + await inboxLoggedIn.getByPlaceholder('Enter Last Name').fill('1'); + await inboxLoggedIn.getByPlaceholder('(555) 555-5555').fill('(111) 111-11111'); + await inboxLoggedIn.getByPlaceholder('Enter Email').fill('1@1'); + await inboxLoggedIn.getByRole('button', { name: 'Add' }).click(); + await inboxLoggedIn + .getByText('I confirm that the owner information provided above matches the current Certific') + .click(); + await inboxLoggedIn.getByText('Other Owned Parcels', { exact: true }).click(); + + // Step 2: Other Parcels + await inboxLoggedIn.getByRole('button', { name: 'Yes' }).click(); + await inboxLoggedIn + .getByLabel('Describe the other parcels including their location, who owns or leases them, and their use.') + .fill('Other parcels'); + await inboxLoggedIn.getByText('Primary Contact', { exact: true }).click(); + + // Step 3: Primary Contact + await inboxLoggedIn.getByRole('button', { name: 'Yes' }).click(); + await inboxLoggedIn.getByLabel('1 1').check(); + await inboxLoggedIn.getByText('Government', { exact: true }).click(); + + // Step 4: Government + await inboxLoggedIn.getByPlaceholder('Type government').click(); + await inboxLoggedIn.getByPlaceholder('Type government').fill('peace'); + await inboxLoggedIn.getByText('Peace River Regional District').click(); + await inboxLoggedIn.getByText('Land Use').click(); + + // Step 5: Land Use + await inboxLoggedIn.getByLabel('Describe all agriculture that currently takes place on the parcel(s).').fill('This'); + await inboxLoggedIn.getByLabel('Describe all agricultural improvements made to the parcel(s).').fill('That'); + await inboxLoggedIn + .getByLabel('Describe all other uses that currently take place on the parcel(s).') + .fill('The other'); + console.log(); + + // North + await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(0).click(); + await inboxLoggedIn.getByRole('option', { name: 'Agricultural / Farm' }).click(); + await inboxLoggedIn.getByRole('textbox', { name: 'North land use type description' }).click(); + await inboxLoggedIn.getByRole('textbox', { name: 'North land use type description' }).fill('1'); + await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(0).press('Escape'); // Close dropdown + + // East + await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(1).click(); + await inboxLoggedIn.getByRole('option', { name: 'Civic / Institutional' }).click(); + await inboxLoggedIn.getByRole('textbox', { name: 'East land use type description' }).click(); + await inboxLoggedIn.getByRole('textbox', { name: 'East land use type description' }).fill('1'); + await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(1).press('Escape'); // Close dropdown + + // South + await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(2).click(); + await inboxLoggedIn.getByRole('option', { name: 'Commercial / Retail' }).click(); + await inboxLoggedIn.getByRole('textbox', { name: 'South land use type description' }).click(); + await inboxLoggedIn.getByRole('textbox', { name: 'South land use type description' }).fill('1'); + await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(2).press('Escape'); // Close dropdown + + // West + await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(3).click(); + await inboxLoggedIn.getByRole('option', { name: 'Industrial' }).click(); + await inboxLoggedIn.getByRole('textbox', { name: 'West land use type description' }).click(); + await inboxLoggedIn.getByRole('textbox', { name: 'West land use type description' }).fill('1'); + await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(3).press('Escape'); // Close dropdown + + await inboxLoggedIn.getByText('Proposal', { exact: true }).click(); + + // Step 6: Proposal + await inboxLoggedIn.getByLabel('What is the purpose of the proposal?').fill('This'); + await inboxLoggedIn + .getByLabel( + 'Specify any agricultural activities such as livestock operations, greenhouses or horticultural activities in proximity to the proposal.', + ) + .fill('That'); + await inboxLoggedIn + .getByLabel('What steps will you take to reduce potential negative impacts on surrounding agricultural lands?') + .fill('The other'); + await inboxLoggedIn + .getByLabel('Could this proposal be accommodated on lands outside of the ALR?') + .fill('And another'); + await inboxLoggedIn.getByPlaceholder('Type total area').fill('1'); + await inboxLoggedIn + .getByText('I confirm that all affected property owners with land in the ALR have been notif') + .click(); + + // File upload + const proofOfServiceNoticeFileChooserPromise = inboxLoggedIn.waitForEvent('filechooser'); + await inboxLoggedIn.getByRole('button', { name: 'Choose file to Upload', exact: true }).nth(0).click(); + const proofOfServiceNoticeFileChooser = await proofOfServiceNoticeFileChooserPromise; + proofOfServiceNoticeFileChooser.setFiles('data/temp.txt'); + + // File upload + const proposalMapFileChooserPromise = inboxLoggedIn.waitForEvent('filechooser'); + await inboxLoggedIn.getByRole('button', { name: 'Choose file to Upload', exact: true }).nth(1).click(); + const proposalMapFileChooser = await proposalMapFileChooserPromise; + proposalMapFileChooser.setFiles('data/temp.txt'); + + await inboxLoggedIn.getByText('Upload Attachments').click(); + + // Step 7: Optional attachments + // File upload first file + const optionalFile1ChooserPromise = inboxLoggedIn.waitForEvent('filechooser'); + await inboxLoggedIn.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); + const optionalFile1Chooser = await optionalFile1ChooserPromise; + optionalFile1Chooser.setFiles('data/temp.txt'); + await inboxLoggedIn.getByPlaceholder('Select a type').nth(0).click(); + await inboxLoggedIn.getByText('Professional Report').click(); + await inboxLoggedIn.getByPlaceholder('Type description').nth(0).fill('Desc'); + + // File upload second file + const optionalFile2ChooserPromise = inboxLoggedIn.waitForEvent('filechooser'); + await inboxLoggedIn.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); + const optionalFile2Chooser = await optionalFile2ChooserPromise; + optionalFile2Chooser.setFiles('data/temp.txt'); + await inboxLoggedIn.getByPlaceholder('Select a type').nth(1).click(); + await inboxLoggedIn.getByText('Site Photo').click(); + await inboxLoggedIn.getByPlaceholder('Type description').nth(1).fill('Desc'); +}); From 9f23ce24b8d6cf1c1360642d8dc60c18a35d44e5 Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Fri, 12 Apr 2024 12:37:58 -0700 Subject: [PATCH 012/103] Add parcel 1 review assertions --- e2e/tests/portal/tur.spec.ts | 39 ++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/e2e/tests/portal/tur.spec.ts b/e2e/tests/portal/tur.spec.ts index f92c0f9088..25fe45195e 100644 --- a/e2e/tests/portal/tur.spec.ts +++ b/e2e/tests/portal/tur.spec.ts @@ -66,35 +66,26 @@ test('TUR', async ({ inboxLoggedIn }) => { await inboxLoggedIn .getByLabel('Describe all other uses that currently take place on the parcel(s).') .fill('The other'); - console.log(); // North await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(0).click(); - await inboxLoggedIn.getByRole('option', { name: 'Agricultural / Farm' }).click(); - await inboxLoggedIn.getByRole('textbox', { name: 'North land use type description' }).click(); + await inboxLoggedIn.locator('#northLandUseType-panel').getByRole('option', { name: 'Agricultural / Farm' }).click(); await inboxLoggedIn.getByRole('textbox', { name: 'North land use type description' }).fill('1'); - await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(0).press('Escape'); // Close dropdown // East await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(1).click(); - await inboxLoggedIn.getByRole('option', { name: 'Civic / Institutional' }).click(); - await inboxLoggedIn.getByRole('textbox', { name: 'East land use type description' }).click(); + await inboxLoggedIn.locator('#eastLandUseType-panel').getByRole('option', { name: 'Civic / Institutional' }).click(); await inboxLoggedIn.getByRole('textbox', { name: 'East land use type description' }).fill('1'); - await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(1).press('Escape'); // Close dropdown // South await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(2).click(); - await inboxLoggedIn.getByRole('option', { name: 'Commercial / Retail' }).click(); - await inboxLoggedIn.getByRole('textbox', { name: 'South land use type description' }).click(); + await inboxLoggedIn.locator('#southLandUseType-panel').getByRole('option', { name: 'Commercial / Retail' }).click(); await inboxLoggedIn.getByRole('textbox', { name: 'South land use type description' }).fill('1'); - await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(2).press('Escape'); // Close dropdown // West await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(3).click(); - await inboxLoggedIn.getByRole('option', { name: 'Industrial' }).click(); - await inboxLoggedIn.getByRole('textbox', { name: 'West land use type description' }).click(); + await inboxLoggedIn.locator('#westLandUseType-panel').getByRole('option', { name: 'Industrial' }).click(); await inboxLoggedIn.getByRole('textbox', { name: 'West land use type description' }).fill('1'); - await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(3).press('Escape'); // Close dropdown await inboxLoggedIn.getByText('Proposal', { exact: true }).click(); @@ -148,4 +139,26 @@ test('TUR', async ({ inboxLoggedIn }) => { await inboxLoggedIn.getByPlaceholder('Select a type').nth(1).click(); await inboxLoggedIn.getByText('Site Photo').click(); await inboxLoggedIn.getByPlaceholder('Type description').nth(1).fill('Desc'); + + await inboxLoggedIn.getByText('Review & Submit').click(); + + // Step 8: Review + // Parcel 1 + await expect(inboxLoggedIn.getByTestId('parcel-0-type')).toHaveText('Fee Simple'); + await expect(inboxLoggedIn.getByTestId('parcel-0-legal-description')).toHaveText('Parcel description'); + await expect(inboxLoggedIn.getByTestId('parcel-0-map-area')).toHaveText('1 ha'); + await expect(inboxLoggedIn.getByTestId('parcel-0-pid')).toHaveText('111-111-111'); + await expect(inboxLoggedIn.getByTestId('parcel-0-purchase-date')).toHaveText('Apr 23, 2014'); + await expect(inboxLoggedIn.getByTestId('parcel-0-is-farm')).toHaveText('Yes'); + await expect(inboxLoggedIn.getByTestId('parcel-0-civic-address')).toHaveText('123 Street Rd'); + await expect(inboxLoggedIn.getByTestId('parcel-0-certificate-of-title')).toHaveText('temp.txt'); + + // Owners + await expect(inboxLoggedIn.getByTestId('parcel-0-owner-0-name')).toHaveText('1 1'); + await expect(inboxLoggedIn.getByTestId('parcel-0-owner-0-organization')).toHaveText('No Data'); + await expect(inboxLoggedIn.getByTestId('parcel-0-owner-0-phone-number')).toHaveText('(111) 111-1111'); + await expect(inboxLoggedIn.getByTestId('parcel-0-owner-0-email')).toHaveText('1@1'); + await expect(inboxLoggedIn.getByTestId('parcel-0-owner-0-corporate-summary')).toHaveText('Not Applicable'); + + await expect(inboxLoggedIn.getByTestId('parcel-0-is-confirmed-by-applicant')).toHaveText('Yes'); }); From 3c7df10dabf3c1bdcf8a47a76f4979d60d9b0ae1 Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Fri, 12 Apr 2024 18:08:18 -0700 Subject: [PATCH 013/103] Add missed test upload file --- e2e/data/temp.txt | 1 + .../parcel/parcel.component.html | 42 ++++++++++++------- 2 files changed, 27 insertions(+), 16 deletions(-) create mode 100644 e2e/data/temp.txt diff --git a/e2e/data/temp.txt b/e2e/data/temp.txt new file mode 100644 index 0000000000..5eced95754 --- /dev/null +++ b/e2e/data/temp.txt @@ -0,0 +1 @@ +Foobar \ No newline at end of file diff --git a/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.html b/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.html index 0dc66f22f6..9f21e051f6 100644 --- a/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.html +++ b/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.html @@ -25,13 +25,13 @@

Parcel #{{ parcelInd + 1 }}

Parcel Type
-
+
{{ parcel.ownershipType?.label }}
Legal Description
-
+
{{ parcel.legalDescription }} Parcel #{{ parcelInd + 1 }} >
Approx. Map Area
-
+
{{ parcel.mapAreaHectares }} ha Parcel #{{ parcelInd + 1 }}
PID {{ parcel.ownershipType.code === PARCEL_OWNERSHIP_TYPES.CROWN ? '(optional)' : '' }}
-
+
{{ parcel.pid | mask : '000-000-000' }} Parcel #{{ parcelInd + 1 }}
PIN (optional)
-
+
{{ parcel.pin }} Parcel #{{ parcelInd + 1 }}
Purchase Date
-
+
{{ parcel.purchasedDate | date }} Parcel #{{ parcelInd + 1 }}
Farm Classification
-
+
{{ parcel.isFarm ? 'Yes' : 'No' }}
Civic Address
-
+
{{ parcel.civicAddress }}
Certificate Of Title
-
+
@@ -146,15 +150,21 @@
Government Parcel Contact
Phone
Email
Corporate Summary
- -
{{ owner.displayName }}
-
+ +
+ {{ owner.displayName }} +
+
{{ owner.organizationName }}
-
{{ owner.phoneNumber ?? '' | mask : '(000) 000-0000' }}
-
{{ owner.email }}
-
+
+ {{ owner.phoneNumber ?? '' | mask : '(000) 000-0000' }} +
+
+ {{ owner.email }} +
+
{{ owner.corporateSummary.fileName }} @@ -174,7 +184,7 @@
Government Parcel Contact
I confirm that the owner information provided above matches the current Certificate of Title
-
+
Yes
From 87be43617664c2517e3b4a4c498d08a9ab35456c Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Fri, 12 Apr 2024 18:39:09 -0700 Subject: [PATCH 014/103] Add other parcel and primary contact review assertions --- e2e/tests/portal/tur.spec.ts | 13 +++++++++++++ .../application-details.component.html | 18 +++++++++--------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/e2e/tests/portal/tur.spec.ts b/e2e/tests/portal/tur.spec.ts index 25fe45195e..2d7bdc0324 100644 --- a/e2e/tests/portal/tur.spec.ts +++ b/e2e/tests/portal/tur.spec.ts @@ -143,6 +143,7 @@ test('TUR', async ({ inboxLoggedIn }) => { await inboxLoggedIn.getByText('Review & Submit').click(); // Step 8: Review + // Parcels // Parcel 1 await expect(inboxLoggedIn.getByTestId('parcel-0-type')).toHaveText('Fee Simple'); await expect(inboxLoggedIn.getByTestId('parcel-0-legal-description')).toHaveText('Parcel description'); @@ -161,4 +162,16 @@ test('TUR', async ({ inboxLoggedIn }) => { await expect(inboxLoggedIn.getByTestId('parcel-0-owner-0-corporate-summary')).toHaveText('Not Applicable'); await expect(inboxLoggedIn.getByTestId('parcel-0-is-confirmed-by-applicant')).toHaveText('Yes'); + + // Other Parcels + await expect(inboxLoggedIn.getByTestId('has-other-parcels')).toHaveText('Yes'); + await expect(inboxLoggedIn.getByTestId('other-parcels-description')).toHaveText('Other parcels'); + + // Primary Contact + await expect(inboxLoggedIn.getByTestId('primary-contact-type')).toHaveText('Land Owner'); + await expect(inboxLoggedIn.getByTestId('primary-contact-first-name')).toHaveText('1'); + await expect(inboxLoggedIn.getByTestId('primary-contact-last-name')).toHaveText('1'); + await expect(inboxLoggedIn.getByTestId('primary-contact-organization')).toHaveText('No Data'); + await expect(inboxLoggedIn.getByTestId('primary-contact-phone-number')).toHaveText('(111) 111-1111'); + await expect(inboxLoggedIn.getByTestId('primary-contact-email')).toHaveText('1@1'); }); diff --git a/portal-frontend/src/app/features/applications/application-details/application-details.component.html b/portal-frontend/src/app/features/applications/application-details/application-details.component.html index 5e3ba672d1..5ea9457679 100644 --- a/portal-frontend/src/app/features/applications/application-details/application-details.component.html +++ b/portal-frontend/src/app/features/applications/application-details/application-details.component.html @@ -13,7 +13,7 @@

2. Other Owned Parcels

Do any of the land owners added previously own or lease other parcels that might inform this application process?
-
+
{{ applicationSubmission.hasOtherParcelsInCommunity ? 'Yes' : 'No' }} @@ -26,7 +26,7 @@

2. Other Owned Parcels

Describe the other parcels including their location, who owns or leases them, and their use.
-
+
{{ applicationSubmission.otherParcelsDescription }}
@@ -40,17 +40,17 @@

2. Other Owned Parcels

3. Primary Contact

Type
-
+
{{ primaryContact?.type?.label }}
First Name
-
+
{{ primaryContact?.firstName }}
Last Name
-
+
{{ primaryContact?.lastName }}
@@ -62,7 +62,7 @@

3. Primary Contact

Ministry/Department Responsible Department
-
+
{{ primaryContact?.organizationName }} 3. Primary Contact >
Phone
-
+
{{ primaryContact?.phoneNumber ?? '' | mask : '(000) 000-0000' }} 3. Primary Contact >
Email
-
+
{{ primaryContact?.email }} Invalid Format
Authorization Letter(s)
-
+
Date: Fri, 12 Apr 2024 19:28:31 -0700 Subject: [PATCH 015/103] Add remaining review assertions --- e2e/tests/portal/tur.spec.ts | 43 +++++++++++++++++-- .../application-details.component.html | 32 +++++++------- .../tur-details/tur-details.component.html | 16 +++---- 3 files changed, 64 insertions(+), 27 deletions(-) diff --git a/e2e/tests/portal/tur.spec.ts b/e2e/tests/portal/tur.spec.ts index 2d7bdc0324..31abd33dd2 100644 --- a/e2e/tests/portal/tur.spec.ts +++ b/e2e/tests/portal/tur.spec.ts @@ -143,7 +143,7 @@ test('TUR', async ({ inboxLoggedIn }) => { await inboxLoggedIn.getByText('Review & Submit').click(); // Step 8: Review - // Parcels + // 1. Parcels // Parcel 1 await expect(inboxLoggedIn.getByTestId('parcel-0-type')).toHaveText('Fee Simple'); await expect(inboxLoggedIn.getByTestId('parcel-0-legal-description')).toHaveText('Parcel description'); @@ -163,15 +163,52 @@ test('TUR', async ({ inboxLoggedIn }) => { await expect(inboxLoggedIn.getByTestId('parcel-0-is-confirmed-by-applicant')).toHaveText('Yes'); - // Other Parcels + // 2. Other Parcels await expect(inboxLoggedIn.getByTestId('has-other-parcels')).toHaveText('Yes'); await expect(inboxLoggedIn.getByTestId('other-parcels-description')).toHaveText('Other parcels'); - // Primary Contact + // 3. Primary Contact await expect(inboxLoggedIn.getByTestId('primary-contact-type')).toHaveText('Land Owner'); await expect(inboxLoggedIn.getByTestId('primary-contact-first-name')).toHaveText('1'); await expect(inboxLoggedIn.getByTestId('primary-contact-last-name')).toHaveText('1'); await expect(inboxLoggedIn.getByTestId('primary-contact-organization')).toHaveText('No Data'); await expect(inboxLoggedIn.getByTestId('primary-contact-phone-number')).toHaveText('(111) 111-1111'); await expect(inboxLoggedIn.getByTestId('primary-contact-email')).toHaveText('1@1'); + + // 4. Government + await expect(inboxLoggedIn.getByTestId('government-name')).toHaveText('Peace River Regional District'); + + // 5. Land Use + await expect(inboxLoggedIn.getByTestId('parcels-agriculture-description')).toHaveText('This'); + await expect(inboxLoggedIn.getByTestId('parcels-agriculture-improvement-description')).toHaveText('That'); + await expect(inboxLoggedIn.getByTestId('parcels-non-agriculture-description')).toHaveText('The other'); + await expect(inboxLoggedIn.getByTestId('north-land-use-type')).toHaveText('Agricultural / Farm'); + await expect(inboxLoggedIn.getByTestId('north-land-use-description')).toHaveText('1'); + await expect(inboxLoggedIn.getByTestId('east-land-use-type')).toHaveText('Civic / Institutional'); + await expect(inboxLoggedIn.getByTestId('east-land-use-description')).toHaveText('1'); + await expect(inboxLoggedIn.getByTestId('south-land-use-type')).toHaveText('Commercial / Retail'); + await expect(inboxLoggedIn.getByTestId('south-land-use-description')).toHaveText('1'); + await expect(inboxLoggedIn.getByTestId('west-land-use-type')).toHaveText('Industrial'); + await expect(inboxLoggedIn.getByTestId('west-land-use-description')).toHaveText('1'); + + // 6. Proposal + await expect(inboxLoggedIn.getByTestId('tur-purpose')).toHaveText('This'); + await expect(inboxLoggedIn.getByTestId('tur-agricultural-activities')).toHaveText('That'); + await expect(inboxLoggedIn.getByTestId('tur-reduce-negative-impacts')).toHaveText('The other'); + await expect(inboxLoggedIn.getByTestId('tur-outside-lands')).toHaveText('And another'); + await expect(inboxLoggedIn.getByTestId('tur-total-corridor-area')).toHaveText('1 ha'); + await expect(inboxLoggedIn.getByTestId('tur-all-owners-notified')).toHaveText('Yes'); + await expect(inboxLoggedIn.getByTestId('tur-proof-of-serving-notice')).toHaveText('temp.txt'); + await expect(inboxLoggedIn.getByTestId('tur-proposal-map')).toHaveText('temp.txt'); + + // 7. Optional Documents + // Doc 1 + await expect(inboxLoggedIn.getByTestId('optional-document-0-file-name')).toHaveText('temp.txt'); + await expect(inboxLoggedIn.getByTestId('optional-document-0-type')).toHaveText('Professional Report'); + await expect(inboxLoggedIn.getByTestId('optional-document-0-description')).toHaveText('Desc'); + + // Doc 2 + await expect(inboxLoggedIn.getByTestId('optional-document-1-file-name')).toHaveText('temp.txt'); + await expect(inboxLoggedIn.getByTestId('optional-document-1-type')).toHaveText('Site Photo'); + await expect(inboxLoggedIn.getByTestId('optional-document-1-description')).toHaveText('Desc'); }); diff --git a/portal-frontend/src/app/features/applications/application-details/application-details.component.html b/portal-frontend/src/app/features/applications/application-details/application-details.component.html index 5ea9457679..c633ffe485 100644 --- a/portal-frontend/src/app/features/applications/application-details/application-details.component.html +++ b/portal-frontend/src/app/features/applications/application-details/application-details.component.html @@ -108,7 +108,7 @@

3. Primary Contact

4. Government

Local or First Nation Government
-
+
{{ localGovernment?.name }} @@ -144,7 +144,7 @@

5. Land Use

Land Use of Parcel(s) under Application

Describe all agriculture that currently takes place on the parcel(s).
-
+
{{ applicationSubmission.parcelsAgricultureDescription }} Land Use of Parcel(s) under Application >
Describe all agricultural improvements made to the parcel(s).
-
+
{{ applicationSubmission.parcelsAgricultureImprovementDescription }} Land Use of Parcel(s) under Application >
Describe all other uses that currently take place on the parcel(s).
-
+
{{ applicationSubmission.parcelsNonAgricultureUseDescription }} Land Use of Adjacent Parcels
Main Land Use Type
Specific Activity
North
-
+
{{ applicationSubmission.northLandUseType }}
-
+
{{ applicationSubmission.northLandUseTypeDescription }} Land Use of Adjacent Parcels >
East
-
+
{{ applicationSubmission.eastLandUseType }}
-
+
{{ applicationSubmission.eastLandUseTypeDescription }} Land Use of Adjacent Parcels >
South
-
+
{{ applicationSubmission.southLandUseType }}
-
+
{{ applicationSubmission.southLandUseTypeDescription }} Land Use of Adjacent Parcels >
West
-
+
{{ applicationSubmission.westLandUseType }}
-
+
{{ applicationSubmission.westLandUseTypeDescription }} 7. Optional Documents
Type
Description
- -
+ + -
+
{{ file.type?.label }}
-
+
{{ file.description }}
diff --git a/portal-frontend/src/app/features/applications/application-details/tur-details/tur-details.component.html b/portal-frontend/src/app/features/applications/application-details/tur-details/tur-details.component.html index e33da56b0a..a405d6db94 100644 --- a/portal-frontend/src/app/features/applications/application-details/tur-details/tur-details.component.html +++ b/portal-frontend/src/app/features/applications/application-details/tur-details/tur-details.component.html @@ -1,6 +1,6 @@
What is the purpose of the proposal?
-
+
{{ _applicationSubmission.purpose }}
@@ -8,24 +8,24 @@ Specify any agricultural activities such as livestock operations, greenhouses or horticultural activities in proximity to the proposal.
-
+
{{ _applicationSubmission.turAgriculturalActivities }}
What steps will you take to reduce potential negative impacts on surrounding agricultural lands?
-
+
{{ _applicationSubmission.turReduceNegativeImpacts }}
Could this proposal be accommodated on lands outside of the ALR?
-
+
{{ _applicationSubmission.turOutsideLands }}
Total area of corridor
-
+
{{ _applicationSubmission.turTotalCorridorArea }} ha @@ -34,19 +34,19 @@
I confirm that all affected property owners with land in the ALR have been notified.
-
+
Yes
Proof of Serving Notice
-
+
Proposal Map / Site Plan
-
+
{{ file.fileName }} From 1466c434f545ace02ef9551e5415ebff41c133df Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Fri, 12 Apr 2024 19:29:05 -0700 Subject: [PATCH 016/103] Fix TUR test using not yet added user type --- e2e/tests/portal/tur.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/portal/tur.spec.ts b/e2e/tests/portal/tur.spec.ts index 31abd33dd2..51cfb0605b 100644 --- a/e2e/tests/portal/tur.spec.ts +++ b/e2e/tests/portal/tur.spec.ts @@ -1,6 +1,6 @@ import { test, expect, UserPrefix } from './fixtures'; -test.use({ userPrefix: UserPrefix.BceidLg }); +test.use({ userPrefix: UserPrefix.BceidBasic }); test('TUR', async ({ inboxLoggedIn }) => { // Create TUR app From b19980438cec50a66ca41ceee0c11f6b511ef2aa Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Thu, 18 Apr 2024 16:42:34 -0700 Subject: [PATCH 017/103] Make login page POM and change the way login works To allow for checking data in ALCS, multiple browser contexts are needed. The fixture method breaks down under these conditions. --- e2e/tests/portal/fixtures.ts | 30 --- e2e/tests/portal/login.spec.ts | 7 - e2e/tests/portal/pages/login-page.ts | 29 +++ e2e/tests/portal/tur.spec.ts | 266 +++++++++++++-------------- 4 files changed, 161 insertions(+), 171 deletions(-) delete mode 100644 e2e/tests/portal/fixtures.ts delete mode 100644 e2e/tests/portal/login.spec.ts create mode 100644 e2e/tests/portal/pages/login-page.ts diff --git a/e2e/tests/portal/fixtures.ts b/e2e/tests/portal/fixtures.ts deleted file mode 100644 index b171c33a7f..0000000000 --- a/e2e/tests/portal/fixtures.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { test as base, Page } from '@playwright/test'; -export { expect } from '@playwright/test'; - -export enum UserPrefix { - BceidBasic = 'BCEID_BASIC', -} - -interface FixtureOptions { - userPrefix: string; -} - -interface Fixtures { - inboxLoggedIn: Page; -} - -export const test = base.extend({ - userPrefix: UserPrefix.BceidBasic, - inboxLoggedIn: async ({ page, userPrefix }, use) => { - await page.goto(process.env.PORTAL_BASE_URL); - await page.getByRole('button', { name: 'Portal Login' }).click(); - await page - .locator('#user') - .fill(process.env[userPrefix + '_USERNAME'] ?? ''); - await page - .getByLabel('Password') - .fill(process.env[userPrefix + '_PASSWORD'] ?? ''); - await page.getByRole('button', { name: /continue/i }).click(); - await use(page); - }, -}); diff --git a/e2e/tests/portal/login.spec.ts b/e2e/tests/portal/login.spec.ts deleted file mode 100644 index 61321d8315..0000000000 --- a/e2e/tests/portal/login.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { test, expect, UserPrefix } from './fixtures'; - -test.use({ userPrefix: UserPrefix.BceidBasic }); - -test('should redirect to inbox after login', async ({ inboxLoggedIn }) => { - await expect(inboxLoggedIn.getByRole('heading', { name: 'Portal Inbox' })).toBeVisible(); -}); diff --git a/e2e/tests/portal/pages/login-page.ts b/e2e/tests/portal/pages/login-page.ts new file mode 100644 index 0000000000..a42bdcf647 --- /dev/null +++ b/e2e/tests/portal/pages/login-page.ts @@ -0,0 +1,29 @@ +import { type Locator, type Page } from '@playwright/test'; + +export class LoginPage { + readonly page: Page; + readonly loginButton: Locator; + readonly userIdTextField: Locator; + readonly passwordTextField: Locator; + readonly continueButton: Locator; + + constructor(page: Page) { + this.page = page; + this.loginButton = page.getByRole('button', { name: 'Portal Login' }); + // There is an error with the username label on BCeID page + this.userIdTextField = page.getByRole('textbox').nth(0); + this.passwordTextField = page.getByLabel('Password'); + this.continueButton = page.getByRole('button', { name: 'Continue' }); + } + + async goto() { + await this.page.goto('/'); + } + + async logIn(username: string, password: string) { + await this.loginButton.click(); + await this.userIdTextField.fill(username); + await this.passwordTextField.fill(password); + await this.continueButton.click(); + } +} diff --git a/e2e/tests/portal/tur.spec.ts b/e2e/tests/portal/tur.spec.ts index 51cfb0605b..01aea83dce 100644 --- a/e2e/tests/portal/tur.spec.ts +++ b/e2e/tests/portal/tur.spec.ts @@ -1,214 +1,212 @@ -import { test, expect, UserPrefix } from './fixtures'; +import { test, expect } from '@playwright/test'; +import { LoginPage } from './pages/login-page'; -test.use({ userPrefix: UserPrefix.BceidBasic }); +test('TUR', async ({ browser }) => { + const context = await browser.newContext({ baseURL: process.env.PORTAL_BASE_URL }); + const page = await context.newPage(); + + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.logIn(process.env.BCEID_BASIC_USERNAME, process.env.BCEID_BASIC_PASSWORD); -test('TUR', async ({ inboxLoggedIn }) => { // Create TUR app - await inboxLoggedIn.getByRole('button', { name: '+ Create New' }).click(); - await inboxLoggedIn.getByText('Application', { exact: true }).click(); - await inboxLoggedIn.getByRole('button', { name: 'Next' }).click(); - await inboxLoggedIn.getByText('Transportation, Utility, or Recreational Trail Uses within the ALR').click(); - await inboxLoggedIn.getByRole('button', { name: 'create' }).click(); - await inboxLoggedIn.getByText('Parcel Details', { exact: true }).click(); // Ensure parcels page + await page.getByRole('button', { name: '+ Create New' }).click(); + await page.getByText('Application', { exact: true }).click(); + await page.getByRole('button', { name: 'Next' }).click(); + await page.getByText('Transportation, Utility, or Recreational Trail Uses within the ALR').click(); + await page.getByRole('button', { name: 'create' }).click(); + await page.getByText('Parcel Details', { exact: true }).click(); // Ensure parcels page // Step 1a: Parcels - await inboxLoggedIn.getByRole('button', { name: 'Fee Simple' }).click(); - await inboxLoggedIn.getByPlaceholder('Type legal description').fill('Parcel description'); - await inboxLoggedIn.getByPlaceholder('Type parcel size').fill('1'); - await inboxLoggedIn.getByPlaceholder('Type PID').fill('111-111-111'); - await inboxLoggedIn.getByRole('button', { name: 'Open calendar' }).click(); - await inboxLoggedIn.getByText('2014').click(); - await inboxLoggedIn.getByText('Apr').click(); - await inboxLoggedIn.getByText('23').click(); - await inboxLoggedIn.getByText('Yes').click(); - await inboxLoggedIn.getByPlaceholder('Type Address').fill('123 Street Rd'); + await page.getByRole('button', { name: 'Fee Simple' }).click(); + await page.getByPlaceholder('Type legal description').fill('Parcel description'); + await page.getByPlaceholder('Type parcel size').fill('1'); + await page.getByPlaceholder('Type PID').fill('111-111-111'); + await page.getByRole('button', { name: 'Open calendar' }).click(); + await page.getByText('2014').click(); + await page.getByText('Apr').click(); + await page.getByText('23').click(); + await page.getByText('Yes').click(); + await page.getByPlaceholder('Type Address').fill('123 Street Rd'); // Upload - const titleFileChooserPromise = inboxLoggedIn.waitForEvent('filechooser'); - await inboxLoggedIn.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); + const titleFileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); const titleFileChooser = await titleFileChooserPromise; titleFileChooser.setFiles('data/temp.txt'); // Step 1b: Parcel Owners - await inboxLoggedIn.getByRole('button', { name: 'Add new owner' }).click(); - await inboxLoggedIn.getByRole('button', { name: 'Individual' }).click(); - await inboxLoggedIn.getByPlaceholder('Enter First Name').fill('1'); - await inboxLoggedIn.getByPlaceholder('Enter Last Name').fill('1'); - await inboxLoggedIn.getByPlaceholder('(555) 555-5555').fill('(111) 111-11111'); - await inboxLoggedIn.getByPlaceholder('Enter Email').fill('1@1'); - await inboxLoggedIn.getByRole('button', { name: 'Add' }).click(); - await inboxLoggedIn - .getByText('I confirm that the owner information provided above matches the current Certific') - .click(); - await inboxLoggedIn.getByText('Other Owned Parcels', { exact: true }).click(); + await page.getByRole('button', { name: 'Add new owner' }).click(); + await page.getByRole('button', { name: 'Individual' }).click(); + await page.getByPlaceholder('Enter First Name').fill('1'); + await page.getByPlaceholder('Enter Last Name').fill('1'); + await page.getByPlaceholder('(555) 555-5555').fill('(111) 111-11111'); + await page.getByPlaceholder('Enter Email').fill('1@1'); + await page.getByRole('button', { name: 'Add' }).click(); + await page.getByText('I confirm that the owner information provided above matches the current Certific').click(); + await page.getByText('Other Owned Parcels', { exact: true }).click(); // Step 2: Other Parcels - await inboxLoggedIn.getByRole('button', { name: 'Yes' }).click(); - await inboxLoggedIn + await page.getByRole('button', { name: 'Yes' }).click(); + await page .getByLabel('Describe the other parcels including their location, who owns or leases them, and their use.') .fill('Other parcels'); - await inboxLoggedIn.getByText('Primary Contact', { exact: true }).click(); + await page.getByText('Primary Contact', { exact: true }).click(); // Step 3: Primary Contact - await inboxLoggedIn.getByRole('button', { name: 'Yes' }).click(); - await inboxLoggedIn.getByLabel('1 1').check(); - await inboxLoggedIn.getByText('Government', { exact: true }).click(); + await page.getByRole('button', { name: 'Yes' }).click(); + await page.getByLabel('1 1').check(); + await page.getByText('Government', { exact: true }).click(); // Step 4: Government - await inboxLoggedIn.getByPlaceholder('Type government').click(); - await inboxLoggedIn.getByPlaceholder('Type government').fill('peace'); - await inboxLoggedIn.getByText('Peace River Regional District').click(); - await inboxLoggedIn.getByText('Land Use').click(); + await page.getByPlaceholder('Type government').click(); + await page.getByPlaceholder('Type government').fill('peace'); + await page.getByText('Peace River Regional District').click(); + await page.getByText('Land Use').click(); // Step 5: Land Use - await inboxLoggedIn.getByLabel('Describe all agriculture that currently takes place on the parcel(s).').fill('This'); - await inboxLoggedIn.getByLabel('Describe all agricultural improvements made to the parcel(s).').fill('That'); - await inboxLoggedIn - .getByLabel('Describe all other uses that currently take place on the parcel(s).') - .fill('The other'); + await page.getByLabel('Describe all agriculture that currently takes place on the parcel(s).').fill('This'); + await page.getByLabel('Describe all agricultural improvements made to the parcel(s).').fill('That'); + await page.getByLabel('Describe all other uses that currently take place on the parcel(s).').fill('The other'); // North - await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(0).click(); - await inboxLoggedIn.locator('#northLandUseType-panel').getByRole('option', { name: 'Agricultural / Farm' }).click(); - await inboxLoggedIn.getByRole('textbox', { name: 'North land use type description' }).fill('1'); + await page.getByPlaceholder('Main Land Use Type').nth(0).click(); + await page.locator('#northLandUseType-panel').getByRole('option', { name: 'Agricultural / Farm' }).click(); + await page.getByRole('textbox', { name: 'North land use type description' }).fill('1'); // East - await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(1).click(); - await inboxLoggedIn.locator('#eastLandUseType-panel').getByRole('option', { name: 'Civic / Institutional' }).click(); - await inboxLoggedIn.getByRole('textbox', { name: 'East land use type description' }).fill('1'); + await page.getByPlaceholder('Main Land Use Type').nth(1).click(); + await page.locator('#eastLandUseType-panel').getByRole('option', { name: 'Civic / Institutional' }).click(); + await page.getByRole('textbox', { name: 'East land use type description' }).fill('1'); // South - await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(2).click(); - await inboxLoggedIn.locator('#southLandUseType-panel').getByRole('option', { name: 'Commercial / Retail' }).click(); - await inboxLoggedIn.getByRole('textbox', { name: 'South land use type description' }).fill('1'); + await page.getByPlaceholder('Main Land Use Type').nth(2).click(); + await page.locator('#southLandUseType-panel').getByRole('option', { name: 'Commercial / Retail' }).click(); + await page.getByRole('textbox', { name: 'South land use type description' }).fill('1'); // West - await inboxLoggedIn.getByPlaceholder('Main Land Use Type').nth(3).click(); - await inboxLoggedIn.locator('#westLandUseType-panel').getByRole('option', { name: 'Industrial' }).click(); - await inboxLoggedIn.getByRole('textbox', { name: 'West land use type description' }).fill('1'); + await page.getByPlaceholder('Main Land Use Type').nth(3).click(); + await page.locator('#westLandUseType-panel').getByRole('option', { name: 'Industrial' }).click(); + await page.getByRole('textbox', { name: 'West land use type description' }).fill('1'); - await inboxLoggedIn.getByText('Proposal', { exact: true }).click(); + await page.getByText('Proposal', { exact: true }).click(); // Step 6: Proposal - await inboxLoggedIn.getByLabel('What is the purpose of the proposal?').fill('This'); - await inboxLoggedIn + await page.getByLabel('What is the purpose of the proposal?').fill('This'); + await page .getByLabel( 'Specify any agricultural activities such as livestock operations, greenhouses or horticultural activities in proximity to the proposal.', ) .fill('That'); - await inboxLoggedIn + await page .getByLabel('What steps will you take to reduce potential negative impacts on surrounding agricultural lands?') .fill('The other'); - await inboxLoggedIn - .getByLabel('Could this proposal be accommodated on lands outside of the ALR?') - .fill('And another'); - await inboxLoggedIn.getByPlaceholder('Type total area').fill('1'); - await inboxLoggedIn - .getByText('I confirm that all affected property owners with land in the ALR have been notif') - .click(); + await page.getByLabel('Could this proposal be accommodated on lands outside of the ALR?').fill('And another'); + await page.getByPlaceholder('Type total area').fill('1'); + await page.getByText('I confirm that all affected property owners with land in the ALR have been notif').click(); // File upload - const proofOfServiceNoticeFileChooserPromise = inboxLoggedIn.waitForEvent('filechooser'); - await inboxLoggedIn.getByRole('button', { name: 'Choose file to Upload', exact: true }).nth(0).click(); + const proofOfServiceNoticeFileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).nth(0).click(); const proofOfServiceNoticeFileChooser = await proofOfServiceNoticeFileChooserPromise; proofOfServiceNoticeFileChooser.setFiles('data/temp.txt'); // File upload - const proposalMapFileChooserPromise = inboxLoggedIn.waitForEvent('filechooser'); - await inboxLoggedIn.getByRole('button', { name: 'Choose file to Upload', exact: true }).nth(1).click(); + const proposalMapFileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).nth(1).click(); const proposalMapFileChooser = await proposalMapFileChooserPromise; proposalMapFileChooser.setFiles('data/temp.txt'); - await inboxLoggedIn.getByText('Upload Attachments').click(); + await page.getByText('Upload Attachments').click(); // Step 7: Optional attachments // File upload first file - const optionalFile1ChooserPromise = inboxLoggedIn.waitForEvent('filechooser'); - await inboxLoggedIn.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); + const optionalFile1ChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); const optionalFile1Chooser = await optionalFile1ChooserPromise; optionalFile1Chooser.setFiles('data/temp.txt'); - await inboxLoggedIn.getByPlaceholder('Select a type').nth(0).click(); - await inboxLoggedIn.getByText('Professional Report').click(); - await inboxLoggedIn.getByPlaceholder('Type description').nth(0).fill('Desc'); + await page.getByPlaceholder('Select a type').nth(0).click(); + await page.getByText('Professional Report').click(); + await page.getByPlaceholder('Type description').nth(0).fill('Desc'); // File upload second file - const optionalFile2ChooserPromise = inboxLoggedIn.waitForEvent('filechooser'); - await inboxLoggedIn.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); + const optionalFile2ChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); const optionalFile2Chooser = await optionalFile2ChooserPromise; optionalFile2Chooser.setFiles('data/temp.txt'); - await inboxLoggedIn.getByPlaceholder('Select a type').nth(1).click(); - await inboxLoggedIn.getByText('Site Photo').click(); - await inboxLoggedIn.getByPlaceholder('Type description').nth(1).fill('Desc'); + await page.getByPlaceholder('Select a type').nth(1).click(); + await page.getByText('Site Photo').click(); + await page.getByPlaceholder('Type description').nth(1).fill('Desc'); - await inboxLoggedIn.getByText('Review & Submit').click(); + await page.getByText('Review & Submit').click(); // Step 8: Review // 1. Parcels // Parcel 1 - await expect(inboxLoggedIn.getByTestId('parcel-0-type')).toHaveText('Fee Simple'); - await expect(inboxLoggedIn.getByTestId('parcel-0-legal-description')).toHaveText('Parcel description'); - await expect(inboxLoggedIn.getByTestId('parcel-0-map-area')).toHaveText('1 ha'); - await expect(inboxLoggedIn.getByTestId('parcel-0-pid')).toHaveText('111-111-111'); - await expect(inboxLoggedIn.getByTestId('parcel-0-purchase-date')).toHaveText('Apr 23, 2014'); - await expect(inboxLoggedIn.getByTestId('parcel-0-is-farm')).toHaveText('Yes'); - await expect(inboxLoggedIn.getByTestId('parcel-0-civic-address')).toHaveText('123 Street Rd'); - await expect(inboxLoggedIn.getByTestId('parcel-0-certificate-of-title')).toHaveText('temp.txt'); + await expect(page.getByTestId('parcel-0-type')).toHaveText('Fee Simple'); + await expect(page.getByTestId('parcel-0-legal-description')).toHaveText('Parcel description'); + await expect(page.getByTestId('parcel-0-map-area')).toHaveText('1 ha'); + await expect(page.getByTestId('parcel-0-pid')).toHaveText('111-111-111'); + await expect(page.getByTestId('parcel-0-purchase-date')).toHaveText('Apr 23, 2014'); + await expect(page.getByTestId('parcel-0-is-farm')).toHaveText('Yes'); + await expect(page.getByTestId('parcel-0-civic-address')).toHaveText('123 Street Rd'); + await expect(page.getByTestId('parcel-0-certificate-of-title')).toHaveText('temp.txt'); // Owners - await expect(inboxLoggedIn.getByTestId('parcel-0-owner-0-name')).toHaveText('1 1'); - await expect(inboxLoggedIn.getByTestId('parcel-0-owner-0-organization')).toHaveText('No Data'); - await expect(inboxLoggedIn.getByTestId('parcel-0-owner-0-phone-number')).toHaveText('(111) 111-1111'); - await expect(inboxLoggedIn.getByTestId('parcel-0-owner-0-email')).toHaveText('1@1'); - await expect(inboxLoggedIn.getByTestId('parcel-0-owner-0-corporate-summary')).toHaveText('Not Applicable'); + await expect(page.getByTestId('parcel-0-owner-0-name')).toHaveText('1 1'); + await expect(page.getByTestId('parcel-0-owner-0-organization')).toHaveText('No Data'); + await expect(page.getByTestId('parcel-0-owner-0-phone-number')).toHaveText('(111) 111-1111'); + await expect(page.getByTestId('parcel-0-owner-0-email')).toHaveText('1@1'); + await expect(page.getByTestId('parcel-0-owner-0-corporate-summary')).toHaveText('Not Applicable'); - await expect(inboxLoggedIn.getByTestId('parcel-0-is-confirmed-by-applicant')).toHaveText('Yes'); + await expect(page.getByTestId('parcel-0-is-confirmed-by-applicant')).toHaveText('Yes'); // 2. Other Parcels - await expect(inboxLoggedIn.getByTestId('has-other-parcels')).toHaveText('Yes'); - await expect(inboxLoggedIn.getByTestId('other-parcels-description')).toHaveText('Other parcels'); + await expect(page.getByTestId('has-other-parcels')).toHaveText('Yes'); + await expect(page.getByTestId('other-parcels-description')).toHaveText('Other parcels'); // 3. Primary Contact - await expect(inboxLoggedIn.getByTestId('primary-contact-type')).toHaveText('Land Owner'); - await expect(inboxLoggedIn.getByTestId('primary-contact-first-name')).toHaveText('1'); - await expect(inboxLoggedIn.getByTestId('primary-contact-last-name')).toHaveText('1'); - await expect(inboxLoggedIn.getByTestId('primary-contact-organization')).toHaveText('No Data'); - await expect(inboxLoggedIn.getByTestId('primary-contact-phone-number')).toHaveText('(111) 111-1111'); - await expect(inboxLoggedIn.getByTestId('primary-contact-email')).toHaveText('1@1'); + await expect(page.getByTestId('primary-contact-type')).toHaveText('Land Owner'); + await expect(page.getByTestId('primary-contact-first-name')).toHaveText('1'); + await expect(page.getByTestId('primary-contact-last-name')).toHaveText('1'); + await expect(page.getByTestId('primary-contact-organization')).toHaveText('No Data'); + await expect(page.getByTestId('primary-contact-phone-number')).toHaveText('(111) 111-1111'); + await expect(page.getByTestId('primary-contact-email')).toHaveText('1@1'); // 4. Government - await expect(inboxLoggedIn.getByTestId('government-name')).toHaveText('Peace River Regional District'); + await expect(page.getByTestId('government-name')).toHaveText('Peace River Regional District'); // 5. Land Use - await expect(inboxLoggedIn.getByTestId('parcels-agriculture-description')).toHaveText('This'); - await expect(inboxLoggedIn.getByTestId('parcels-agriculture-improvement-description')).toHaveText('That'); - await expect(inboxLoggedIn.getByTestId('parcels-non-agriculture-description')).toHaveText('The other'); - await expect(inboxLoggedIn.getByTestId('north-land-use-type')).toHaveText('Agricultural / Farm'); - await expect(inboxLoggedIn.getByTestId('north-land-use-description')).toHaveText('1'); - await expect(inboxLoggedIn.getByTestId('east-land-use-type')).toHaveText('Civic / Institutional'); - await expect(inboxLoggedIn.getByTestId('east-land-use-description')).toHaveText('1'); - await expect(inboxLoggedIn.getByTestId('south-land-use-type')).toHaveText('Commercial / Retail'); - await expect(inboxLoggedIn.getByTestId('south-land-use-description')).toHaveText('1'); - await expect(inboxLoggedIn.getByTestId('west-land-use-type')).toHaveText('Industrial'); - await expect(inboxLoggedIn.getByTestId('west-land-use-description')).toHaveText('1'); + await expect(page.getByTestId('parcels-agriculture-description')).toHaveText('This'); + await expect(page.getByTestId('parcels-agriculture-improvement-description')).toHaveText('That'); + await expect(page.getByTestId('parcels-non-agriculture-description')).toHaveText('The other'); + await expect(page.getByTestId('north-land-use-type')).toHaveText('Agricultural / Farm'); + await expect(page.getByTestId('north-land-use-description')).toHaveText('1'); + await expect(page.getByTestId('east-land-use-type')).toHaveText('Civic / Institutional'); + await expect(page.getByTestId('east-land-use-description')).toHaveText('1'); + await expect(page.getByTestId('south-land-use-type')).toHaveText('Commercial / Retail'); + await expect(page.getByTestId('south-land-use-description')).toHaveText('1'); + await expect(page.getByTestId('west-land-use-type')).toHaveText('Industrial'); + await expect(page.getByTestId('west-land-use-description')).toHaveText('1'); // 6. Proposal - await expect(inboxLoggedIn.getByTestId('tur-purpose')).toHaveText('This'); - await expect(inboxLoggedIn.getByTestId('tur-agricultural-activities')).toHaveText('That'); - await expect(inboxLoggedIn.getByTestId('tur-reduce-negative-impacts')).toHaveText('The other'); - await expect(inboxLoggedIn.getByTestId('tur-outside-lands')).toHaveText('And another'); - await expect(inboxLoggedIn.getByTestId('tur-total-corridor-area')).toHaveText('1 ha'); - await expect(inboxLoggedIn.getByTestId('tur-all-owners-notified')).toHaveText('Yes'); - await expect(inboxLoggedIn.getByTestId('tur-proof-of-serving-notice')).toHaveText('temp.txt'); - await expect(inboxLoggedIn.getByTestId('tur-proposal-map')).toHaveText('temp.txt'); + await expect(page.getByTestId('tur-purpose')).toHaveText('This'); + await expect(page.getByTestId('tur-agricultural-activities')).toHaveText('That'); + await expect(page.getByTestId('tur-reduce-negative-impacts')).toHaveText('The other'); + await expect(page.getByTestId('tur-outside-lands')).toHaveText('And another'); + await expect(page.getByTestId('tur-total-corridor-area')).toHaveText('1 ha'); + await expect(page.getByTestId('tur-all-owners-notified')).toHaveText('Yes'); + await expect(page.getByTestId('tur-proof-of-serving-notice')).toHaveText('temp.txt'); + await expect(page.getByTestId('tur-proposal-map')).toHaveText('temp.txt'); // 7. Optional Documents // Doc 1 - await expect(inboxLoggedIn.getByTestId('optional-document-0-file-name')).toHaveText('temp.txt'); - await expect(inboxLoggedIn.getByTestId('optional-document-0-type')).toHaveText('Professional Report'); - await expect(inboxLoggedIn.getByTestId('optional-document-0-description')).toHaveText('Desc'); + await expect(page.getByTestId('optional-document-0-file-name')).toHaveText('temp.txt'); + await expect(page.getByTestId('optional-document-0-type')).toHaveText('Professional Report'); + await expect(page.getByTestId('optional-document-0-description')).toHaveText('Desc'); // Doc 2 - await expect(inboxLoggedIn.getByTestId('optional-document-1-file-name')).toHaveText('temp.txt'); - await expect(inboxLoggedIn.getByTestId('optional-document-1-type')).toHaveText('Site Photo'); - await expect(inboxLoggedIn.getByTestId('optional-document-1-description')).toHaveText('Desc'); + await expect(page.getByTestId('optional-document-1-file-name')).toHaveText('temp.txt'); + await expect(page.getByTestId('optional-document-1-type')).toHaveText('Site Photo'); + await expect(page.getByTestId('optional-document-1-description')).toHaveText('Desc'); }); From 73cf4c82ff8682965283978e75f5daf15fc44c9f Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Thu, 18 Apr 2024 16:47:27 -0700 Subject: [PATCH 018/103] Centralize tests Since we're checking ALCS in the same test, breaking tests into portal/ALCS doesn't make sense --- .../pages/login-page.ts => pages/portal-login-page.ts} | 2 +- e2e/tests/{portal => }/tur.spec.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) rename e2e/tests/{portal/pages/login-page.ts => pages/portal-login-page.ts} (96%) rename e2e/tests/{portal => }/tur.spec.ts (97%) diff --git a/e2e/tests/portal/pages/login-page.ts b/e2e/tests/pages/portal-login-page.ts similarity index 96% rename from e2e/tests/portal/pages/login-page.ts rename to e2e/tests/pages/portal-login-page.ts index a42bdcf647..55da986f33 100644 --- a/e2e/tests/portal/pages/login-page.ts +++ b/e2e/tests/pages/portal-login-page.ts @@ -1,6 +1,6 @@ import { type Locator, type Page } from '@playwright/test'; -export class LoginPage { +export class PortalLoginPage { readonly page: Page; readonly loginButton: Locator; readonly userIdTextField: Locator; diff --git a/e2e/tests/portal/tur.spec.ts b/e2e/tests/tur.spec.ts similarity index 97% rename from e2e/tests/portal/tur.spec.ts rename to e2e/tests/tur.spec.ts index 01aea83dce..db6d842f60 100644 --- a/e2e/tests/portal/tur.spec.ts +++ b/e2e/tests/tur.spec.ts @@ -1,13 +1,13 @@ import { test, expect } from '@playwright/test'; -import { LoginPage } from './pages/login-page'; +import { PortalLoginPage } from './pages/portal-login-page'; test('TUR', async ({ browser }) => { const context = await browser.newContext({ baseURL: process.env.PORTAL_BASE_URL }); const page = await context.newPage(); - const loginPage = new LoginPage(page); - await loginPage.goto(); - await loginPage.logIn(process.env.BCEID_BASIC_USERNAME, process.env.BCEID_BASIC_PASSWORD); + const portalLoginPage = new PortalLoginPage(page); + await portalLoginPage.goto(); + await portalLoginPage.logIn(process.env.BCEID_BASIC_USERNAME, process.env.BCEID_BASIC_PASSWORD); // Create TUR app await page.getByRole('button', { name: '+ Create New' }).click(); From d91344793e800cae947d3c60f91ab0f0f2826c62 Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Thu, 18 Apr 2024 17:11:55 -0700 Subject: [PATCH 019/103] Create inbox POM --- e2e/tests/pages/inbox-page.ts | 53 +++++++++++++++++++++++++++++++++++ e2e/tests/tur.spec.ts | 10 ++----- 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 e2e/tests/pages/inbox-page.ts diff --git a/e2e/tests/pages/inbox-page.ts b/e2e/tests/pages/inbox-page.ts new file mode 100644 index 0000000000..35f273fe14 --- /dev/null +++ b/e2e/tests/pages/inbox-page.ts @@ -0,0 +1,53 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +export enum SubmissionType { + Application = 'Application', + NoticeOfIntent = 'Notice of Intent', + SRW = 'Notification of Statutory Right of Way (SRW)', +} + +export enum ApplicationType { + Inclusion = 'Include Land into the ALR', + NARU = 'Non-Adhering Residential Use within the ALR', + POFO = 'Placement of Fill within the ALR', + PFRS = 'Removal of Soil (Extraction) and Placement of Fill within the ALR', + ROSO = 'Removal of Soil (Extraction) within the ALR', + NFU = 'Non-Farm Uses within the ALR', + Subdivision = 'Subdivide Land in the ALR', + TUR = 'Transportation, Utility, or Recreational Trail Uses within the ALR', + Exclusion = 'Exclude Land from the ALR', + Covenant = 'Register a Restrictive Covenant within the ALR', +} + +export class InboxPage { + readonly page: Page; + readonly startCreateButton: Locator; + readonly nextButton: Locator; + readonly finishCreateButton: Locator; + readonly createNewDialog: Locator; + + constructor(page: Page) { + this.page = page; + this.startCreateButton = page.getByRole('button', { name: '+ Create New' }); + this.nextButton = page.getByRole('button', { name: 'Next' }); + this.finishCreateButton = page.getByRole('button', { name: 'create' }); + this.createNewDialog = page.getByRole('dialog', { name: 'Create New' }); + } + + async createApplication(type: ApplicationType) { + await this.startCreateButton.click(); + await this.setSubmissionType(SubmissionType.Application); + await this.nextButton.click(); + await this.setApplicationType(type); + await this.finishCreateButton.click(); + await expect(this.createNewDialog).toBeHidden(); + } + + async setSubmissionType(type: SubmissionType) { + await this.page.getByRole('radio', { name: type }).check(); + } + + async setApplicationType(type: ApplicationType) { + await this.page.getByRole('radio', { name: type }).click(); + } +} diff --git a/e2e/tests/tur.spec.ts b/e2e/tests/tur.spec.ts index db6d842f60..9281edc66a 100644 --- a/e2e/tests/tur.spec.ts +++ b/e2e/tests/tur.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { PortalLoginPage } from './pages/portal-login-page'; +import { ApplicationType, InboxPage } from './pages/inbox-page'; test('TUR', async ({ browser }) => { const context = await browser.newContext({ baseURL: process.env.PORTAL_BASE_URL }); @@ -9,13 +10,8 @@ test('TUR', async ({ browser }) => { await portalLoginPage.goto(); await portalLoginPage.logIn(process.env.BCEID_BASIC_USERNAME, process.env.BCEID_BASIC_PASSWORD); - // Create TUR app - await page.getByRole('button', { name: '+ Create New' }).click(); - await page.getByText('Application', { exact: true }).click(); - await page.getByRole('button', { name: 'Next' }).click(); - await page.getByText('Transportation, Utility, or Recreational Trail Uses within the ALR').click(); - await page.getByRole('button', { name: 'create' }).click(); - await page.getByText('Parcel Details', { exact: true }).click(); // Ensure parcels page + const inbox = new InboxPage(page); + await inbox.createApplication(ApplicationType.TUR); // Step 1a: Parcels await page.getByRole('button', { name: 'Fee Simple' }).click(); From df6db00dcd6a155d7035876e14cd81b67a312b82 Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Mon, 22 Apr 2024 16:00:47 -0700 Subject: [PATCH 020/103] Fix month--year labels formatted incorrectly Both display and a11y labels were showing only the year, when they should be month and year. --- portal-frontend/src/app/shared/utils/date-format.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/portal-frontend/src/app/shared/utils/date-format.ts b/portal-frontend/src/app/shared/utils/date-format.ts index 4afe8a1c8a..a9e9007243 100644 --- a/portal-frontend/src/app/shared/utils/date-format.ts +++ b/portal-frontend/src/app/shared/utils/date-format.ts @@ -6,9 +6,9 @@ export const DATE_FORMATS = { }, display: { dateInput: environment.dateFormat, - monthYearLabel: 'YYYY', + monthYearLabel: 'MMM YYYY', dateA11yLabel: 'LL', - monthYearA11yLabel: 'YYYY', + monthYearA11yLabel: 'MMMM YYYY', monthLabel: 'MMM', monthDayLabel: 'MMM-DD', popupHeaderDateLabel: 'YYYY-MMM-DD', From 102b4bf6e3e01673d17588b9814664509474aef3 Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Fri, 26 Apr 2024 13:59:11 -0700 Subject: [PATCH 021/103] Align terminology with Playwright role terminology --- e2e/tests/pages/portal-login-page.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/tests/pages/portal-login-page.ts b/e2e/tests/pages/portal-login-page.ts index 55da986f33..85210ab4d1 100644 --- a/e2e/tests/pages/portal-login-page.ts +++ b/e2e/tests/pages/portal-login-page.ts @@ -3,16 +3,16 @@ import { type Locator, type Page } from '@playwright/test'; export class PortalLoginPage { readonly page: Page; readonly loginButton: Locator; - readonly userIdTextField: Locator; - readonly passwordTextField: Locator; + readonly userIdTextbox: Locator; + readonly passwordTextbox: Locator; readonly continueButton: Locator; constructor(page: Page) { this.page = page; this.loginButton = page.getByRole('button', { name: 'Portal Login' }); // There is an error with the username label on BCeID page - this.userIdTextField = page.getByRole('textbox').nth(0); - this.passwordTextField = page.getByLabel('Password'); + this.userIdTextbox = page.getByRole('textbox').nth(0); + this.passwordTextbox = page.getByLabel('Password'); this.continueButton = page.getByRole('button', { name: 'Continue' }); } @@ -22,8 +22,8 @@ export class PortalLoginPage { async logIn(username: string, password: string) { await this.loginButton.click(); - await this.userIdTextField.fill(username); - await this.passwordTextField.fill(password); + await this.userIdTextbox.fill(username); + await this.passwordTextbox.fill(password); await this.continueButton.click(); } } From ec59e2908998bbea7e2766eac83e0f651af0286c Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Thu, 2 May 2024 17:13:07 -0700 Subject: [PATCH 022/103] Convert the rest of the portal sections and review --- e2e/data/temp2.txt | 1 + e2e/tests/pages/portal/government-page.ts | 18 + e2e/tests/pages/portal/inbox-page.ts | 53 +++ e2e/tests/pages/portal/land-use-page.ts | 77 ++++ .../pages/portal/optional-attachments-page.ts | 72 ++++ e2e/tests/pages/portal/other-parcels-page.ts | 23 ++ e2e/tests/pages/portal/parcels-page.ts | 290 ++++++++++++++ e2e/tests/pages/portal/portal-login-page.ts | 29 ++ .../pages/portal/portal-steps-navigation.ts | 57 +++ .../pages/portal/primary-contact-page.ts | 67 ++++ .../government-section.ts | 15 + .../land-use-section.ts | 27 ++ .../optional-documents-section.ts | 28 ++ .../other-owned-parcels-section.ts | 21 + .../review-and-submit-page/parcels-section.ts | 130 +++++++ .../primary-contact-section.ts | 45 +++ .../review-and-submit-page.ts | 64 +++ .../tur-proposal-section.ts | 41 ++ .../pages/portal/submission-success-page.ts | 26 ++ e2e/tests/pages/portal/tur-proposal-page.ts | 65 ++++ e2e/tests/tur.spec.ts | 366 ++++++++---------- .../application-details.component.html | 29 +- .../parcel/parcel.component.html | 38 +- .../land-use/land-use.component.html | 32 +- .../tur-proposal/tur-proposal.component.html | 2 + .../custom-stepper.component.html | 2 +- 26 files changed, 1384 insertions(+), 234 deletions(-) create mode 100644 e2e/data/temp2.txt create mode 100644 e2e/tests/pages/portal/government-page.ts create mode 100644 e2e/tests/pages/portal/inbox-page.ts create mode 100644 e2e/tests/pages/portal/land-use-page.ts create mode 100644 e2e/tests/pages/portal/optional-attachments-page.ts create mode 100644 e2e/tests/pages/portal/other-parcels-page.ts create mode 100644 e2e/tests/pages/portal/parcels-page.ts create mode 100644 e2e/tests/pages/portal/portal-login-page.ts create mode 100644 e2e/tests/pages/portal/portal-steps-navigation.ts create mode 100644 e2e/tests/pages/portal/primary-contact-page.ts create mode 100644 e2e/tests/pages/portal/review-and-submit-page/government-section.ts create mode 100644 e2e/tests/pages/portal/review-and-submit-page/land-use-section.ts create mode 100644 e2e/tests/pages/portal/review-and-submit-page/optional-documents-section.ts create mode 100644 e2e/tests/pages/portal/review-and-submit-page/other-owned-parcels-section.ts create mode 100644 e2e/tests/pages/portal/review-and-submit-page/parcels-section.ts create mode 100644 e2e/tests/pages/portal/review-and-submit-page/primary-contact-section.ts create mode 100644 e2e/tests/pages/portal/review-and-submit-page/review-and-submit-page.ts create mode 100644 e2e/tests/pages/portal/review-and-submit-page/tur-proposal-section.ts create mode 100644 e2e/tests/pages/portal/submission-success-page.ts create mode 100644 e2e/tests/pages/portal/tur-proposal-page.ts diff --git a/e2e/data/temp2.txt b/e2e/data/temp2.txt new file mode 100644 index 0000000000..5eced95754 --- /dev/null +++ b/e2e/data/temp2.txt @@ -0,0 +1 @@ +Foobar \ No newline at end of file diff --git a/e2e/tests/pages/portal/government-page.ts b/e2e/tests/pages/portal/government-page.ts new file mode 100644 index 0000000000..9c5a2a33bb --- /dev/null +++ b/e2e/tests/pages/portal/government-page.ts @@ -0,0 +1,18 @@ +import { type Locator, type Page } from '@playwright/test'; + +export class GovernmentPage { + readonly page: Page; + readonly textbox: Locator; + + constructor(page: Page) { + this.page = page; + this.textbox = page.getByPlaceholder('Type government'); + } + + async fill(name: string) { + // Type half the name + await this.textbox.fill(name.slice(0, Math.floor(name.length / 2))); + // Find in autocomplete list + await this.page.getByText(name).click(); + } +} diff --git a/e2e/tests/pages/portal/inbox-page.ts b/e2e/tests/pages/portal/inbox-page.ts new file mode 100644 index 0000000000..35f273fe14 --- /dev/null +++ b/e2e/tests/pages/portal/inbox-page.ts @@ -0,0 +1,53 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +export enum SubmissionType { + Application = 'Application', + NoticeOfIntent = 'Notice of Intent', + SRW = 'Notification of Statutory Right of Way (SRW)', +} + +export enum ApplicationType { + Inclusion = 'Include Land into the ALR', + NARU = 'Non-Adhering Residential Use within the ALR', + POFO = 'Placement of Fill within the ALR', + PFRS = 'Removal of Soil (Extraction) and Placement of Fill within the ALR', + ROSO = 'Removal of Soil (Extraction) within the ALR', + NFU = 'Non-Farm Uses within the ALR', + Subdivision = 'Subdivide Land in the ALR', + TUR = 'Transportation, Utility, or Recreational Trail Uses within the ALR', + Exclusion = 'Exclude Land from the ALR', + Covenant = 'Register a Restrictive Covenant within the ALR', +} + +export class InboxPage { + readonly page: Page; + readonly startCreateButton: Locator; + readonly nextButton: Locator; + readonly finishCreateButton: Locator; + readonly createNewDialog: Locator; + + constructor(page: Page) { + this.page = page; + this.startCreateButton = page.getByRole('button', { name: '+ Create New' }); + this.nextButton = page.getByRole('button', { name: 'Next' }); + this.finishCreateButton = page.getByRole('button', { name: 'create' }); + this.createNewDialog = page.getByRole('dialog', { name: 'Create New' }); + } + + async createApplication(type: ApplicationType) { + await this.startCreateButton.click(); + await this.setSubmissionType(SubmissionType.Application); + await this.nextButton.click(); + await this.setApplicationType(type); + await this.finishCreateButton.click(); + await expect(this.createNewDialog).toBeHidden(); + } + + async setSubmissionType(type: SubmissionType) { + await this.page.getByRole('radio', { name: type }).check(); + } + + async setApplicationType(type: ApplicationType) { + await this.page.getByRole('radio', { name: type }).click(); + } +} diff --git a/e2e/tests/pages/portal/land-use-page.ts b/e2e/tests/pages/portal/land-use-page.ts new file mode 100644 index 0000000000..811d61cdf1 --- /dev/null +++ b/e2e/tests/pages/portal/land-use-page.ts @@ -0,0 +1,77 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +export enum Direction { + North = 'north', + East = 'east', + South = 'south', + West = 'west', +} + +export enum LandUseType { + Agricultural = 'Agricultural / Farm', + Civic = 'Civic / Institutional', + Commercial = 'Commercial / Retail', + Industrial = 'Industrial', + Other = 'Other', + Recreational = 'Recreational', + Residential = 'Residential', + Transportation = 'Transportation / Utilities', + Unused = 'Unused', +} + +export interface LandUseDetails { + currentAgriculture: string; + improvements: string; + otherUses: string; + neighbouringLandUse: Map; +} + +interface NeighbouringLandUse { + type: LandUseType; + activity: string; +} + +export class LandUsePage { + readonly page: Page; + readonly currentAgricultureTextField: Locator; + readonly improvementsTextField: Locator; + readonly otherUsesTextField: Locator; + + constructor(page: Page) { + this.page = page; + this.currentAgricultureTextField = page.getByRole('textbox', { + name: 'Describe all agriculture that currently takes place on the parcel(s).', + }); + this.improvementsTextField = page.getByRole('textbox', { + name: 'Describe all agricultural improvements made to the parcel(s).', + }); + this.otherUsesTextField = page.getByRole('textbox', { + name: 'Describe all other uses that currently take place on the parcel(s).', + }); + } + + async fill(landUse: LandUseDetails) { + await this.currentAgricultureTextField.fill(landUse.currentAgriculture); + await this.improvementsTextField.fill(landUse.improvements); + await this.otherUsesTextField.fill(landUse.otherUses); + + for (const [direction, { type, activity }] of landUse.neighbouringLandUse) { + await this.setNeighbouringType(direction, type); + await this.neighbouringActivityTextbox(direction).fill(activity); + } + } + + async setNeighbouringType(direction: Direction, type: LandUseType) { + await this.neighbouringTypeCombobox(direction).click(); + await this.page.getByRole('option', { name: type }).click(); + await expect(this.page.getByRole('listbox')).toBeHidden(); + } + + neighbouringTypeCombobox(direction: Direction): Locator { + return this.page.getByTestId(`${direction}-type-combobox`); + } + + neighbouringActivityTextbox(direction: Direction): Locator { + return this.page.getByTestId(`${direction}-activity-textbox`); + } +} diff --git a/e2e/tests/pages/portal/optional-attachments-page.ts b/e2e/tests/pages/portal/optional-attachments-page.ts new file mode 100644 index 0000000000..4f20756ba1 --- /dev/null +++ b/e2e/tests/pages/portal/optional-attachments-page.ts @@ -0,0 +1,72 @@ +import { type Locator, type Page } from '@playwright/test'; + +// // Step 7: Optional attachments +// // File upload first file +// const optionalFile1ChooserPromise = page.waitForEvent('filechooser'); +// await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); +// const optionalFile1Chooser = await optionalFile1ChooserPromise; +// optionalFile1Chooser.setFiles('data/temp.txt'); +// await page.getByPlaceholder('Select a type').nth(0).click(); +// await page.getByText('Professional Report').click(); +// await page.getByPlaceholder('Type description').nth(0).fill('Desc'); + +// // File upload second file +// const optionalFile2ChooserPromise = page.waitForEvent('filechooser'); +// await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); +// const optionalFile2Chooser = await optionalFile2ChooserPromise; +// optionalFile2Chooser.setFiles('data/temp.txt'); +// await page.getByPlaceholder('Select a type').nth(1).click(); +// await page.getByText('Site Photo').click(); +// await page.getByPlaceholder('Type description').nth(1).fill('Desc'); + +// await page.getByText('Review & Submit').click(); + +export enum OptionalAttachmentType { + SitePhoto = 'Site Photo', + ProfessionalReport = 'Professional Report', + Other = 'Other', +} + +export interface OptionalAttachment { + path: string; + type: OptionalAttachmentType; + description: string; +} + +export class OptionalAttachmentsPage { + readonly page: Page; + readonly uploadButton: Locator; + readonly typeComboboxes: Locator; + readonly descriptionTextboxes: Locator; + + constructor(page: Page) { + this.page = page; + this.uploadButton = page.getByRole('button', { name: 'Choose file to Upload', exact: true }); + this.typeComboboxes = page.getByRole('combobox', { name: 'type' }); + this.descriptionTextboxes = page.getByRole('textbox', { name: 'description' }); + } + + async addAttachments(attachments: OptionalAttachment[]) { + for (const { path, type, description } of attachments) { + await this.uploadAttachment(path); + await this.setLastType(type); + await this.fillLastDescription(description); + } + } + + async uploadAttachment(path: string) { + const fileChooserPromise = this.page.waitForEvent('filechooser'); + await this.uploadButton.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path); + } + + async setLastType(type: OptionalAttachmentType) { + await this.typeComboboxes.last().click(); + await this.page.getByText(type).click(); + } + + async fillLastDescription(description: string) { + await this.descriptionTextboxes.last().fill(description); + } +} diff --git a/e2e/tests/pages/portal/other-parcels-page.ts b/e2e/tests/pages/portal/other-parcels-page.ts new file mode 100644 index 0000000000..9358902ecf --- /dev/null +++ b/e2e/tests/pages/portal/other-parcels-page.ts @@ -0,0 +1,23 @@ +import { type Locator, type Page } from '@playwright/test'; + +export class OtherParcelsPage { + readonly page: Page; + readonly yesButton: Locator; + readonly noButton: Locator; + + constructor(page: Page) { + this.page = page; + this.yesButton = page.getByRole('button', { name: 'Yes' }); + this.noButton = page.getByRole('button', { name: 'No' }); + } + + async setHasOtherParcels(hasOtherParcels: boolean) { + await (hasOtherParcels ? this.yesButton.click() : this.noButton.click()); + } + + async fillDescription(description: string) { + await this.page + .getByLabel('Describe the other parcels including their location, who owns or leases them, and their use.') + .fill(description); + } +} diff --git a/e2e/tests/pages/portal/parcels-page.ts b/e2e/tests/pages/portal/parcels-page.ts new file mode 100644 index 0000000000..c2788e9cd0 --- /dev/null +++ b/e2e/tests/pages/portal/parcels-page.ts @@ -0,0 +1,290 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +export enum ParcelType { + FeeSimple = 'Fee Simple', + Crown = 'Crown', +} + +export enum OwnerType { + Individual = 'Individual', + Organization = 'Organization', + ProvincialCrown = 'Provincial Crown', + FederalCrown = 'Federal Crown', +} + +export interface ParcelDetails { + type: ParcelType; + legalDescription: string; + mapArea: string; + pid?: string; + pin?: string; + year?: string; + month?: string; + day?: string; + isFarm: boolean; + civicAddress: string; + certificateOfTitlePath: string; + isConfirmed: boolean; + owners: ParcelOwnerDetails[]; +} + +export interface ParcelOwnerDetails { + type: OwnerType; + organization?: string; + corporateSummaryPath?: string; + firstName: string; + lastName: string; + phoneNumber: string; + email: string; +} + +export class ParcelsPage { + readonly page: Page; + readonly parcelAddButton: Locator; + readonly ownerAddDialog: Locator; + readonly ownerOrganizationTexbox: Locator; + readonly ownerMinistryTexbox: Locator; + readonly ownerFirstNameTexbox: Locator; + readonly ownerLastNameTexbox: Locator; + readonly ownerPhoneNumberTexbox: Locator; + readonly ownerEmailTexbox: Locator; + readonly ownerSaveButton: Locator; + + constructor(page: Page) { + this.page = page; + this.parcelAddButton = page.getByRole('button', { name: 'Add another parcel to the application' }); + this.ownerAddDialog = page.getByRole('dialog', { name: 'Add New Owner' }); + this.ownerOrganizationTexbox = page.getByPlaceholder('Enter Organization Name'); + this.ownerMinistryTexbox = page.getByPlaceholder('Type ministry or department name'); + this.ownerFirstNameTexbox = page.getByPlaceholder('Enter First Name'); + this.ownerLastNameTexbox = page.getByPlaceholder('Enter Last Name'); + this.ownerPhoneNumberTexbox = page.getByPlaceholder('(555) 555-5555'); + this.ownerEmailTexbox = page.getByPlaceholder('Enter Email'); + this.ownerSaveButton = page.getByRole('button', { name: 'Add' }); + } + + // Scenarios + // --------- + + async fill(parcels: ParcelDetails[]) { + for (const [parcel, i] of parcels.map((parcel, i): [ParcelDetails, number] => [parcel, i])) { + const parcelNumber = i + 1; + + await this.fillParcelDetails(parcelNumber, parcel); + + for (const owner of parcel.owners) { + await this.addOwner(parcelNumber, owner); + } + + if (parcel.isConfirmed) { + await this.checkConfirmationCheckbox(parcelNumber); + } + } + } + + /* Assumptions: + * - parcel must be expanded, so must be added sequentially, though + * this is currently not reliable. + */ + async fillParcelDetails(parcelNumber: number, parcel: ParcelDetails) { + if (parcel.type === ParcelType.FeeSimple) { + // Make sure required details are supplied + await expect(parcel.pid).toBeDefined(); + await expect(parcel.year).toBeDefined(); + await expect(parcel.month).toBeDefined(); + await expect(parcel.day).toBeDefined(); + } + + await this.setParcelType(parcelNumber, parcel.type); + await this.legalDescriptionTexbox(parcelNumber).fill(parcel.legalDescription); + await this.mapAreaTexbox(parcelNumber).fill(parcel.mapArea); + + if (parcel.pid !== undefined) { + await this.pidTexbox(parcelNumber).fill(parcel.pid); + } + + if (parcel.type === ParcelType.Crown && parcel.pin !== undefined) { + await this.pinTexbox(parcelNumber).fill(parcel.pin); + } + + if ( + parcel.type === ParcelType.FeeSimple && + parcel.year !== undefined && + parcel.month !== undefined && + parcel.day !== undefined + ) { + await this.setDate(parcelNumber, parcel.year, parcel.month, parcel.day); + } + + await this.setIsFarm(parcelNumber, parcel.isFarm); + await this.civicAddressTexbox(parcelNumber).fill(parcel.civicAddress); + await this.uploadCertificateOfTitle(parcelNumber, parcel.certificateOfTitlePath); + } + + /* Assumptions: + * - parcel must be expanded, so must be added sequentially, though + * this is currently not reliable. + */ + async addOwner(parcelNumber: number, owner: ParcelOwnerDetails) { + const parcelType = await this.parcelType(parcelNumber); + + if (parcelType === ParcelType.FeeSimple) { + await this.nonCrownOwnerAddButton(parcelNumber).click(); + await expect([OwnerType.Individual, OwnerType.Organization]).toContain(owner.type); + } else if (parcelType === ParcelType.Crown) { + await this.crownOwnerAddButton(parcelNumber).click(); + await expect([OwnerType.ProvincialCrown, OwnerType.FederalCrown]).toContain(owner.type); + } + + if ([OwnerType.Organization, OwnerType.ProvincialCrown, OwnerType.FederalCrown].includes(owner.type)) { + await expect(owner.organization).toBeDefined(); + } + if (owner.type === OwnerType.Organization) { + await expect(owner.corporateSummaryPath).toBeDefined(); + } + + await this.setOwnerType(owner.type); + + if ( + owner.type === OwnerType.Organization && + owner.organization !== undefined && + owner.corporateSummaryPath !== undefined + ) { + await this.ownerOrganizationTexbox.fill(owner.organization); + await this.uploadCorporateSummary(owner.corporateSummaryPath); + } else if ( + (owner.type === OwnerType.ProvincialCrown || owner.type === OwnerType.FederalCrown) && + owner.organization !== undefined + ) { + await this.ownerMinistryTexbox.fill(owner.organization); + } + + // Universal fields + await this.ownerFirstNameTexbox.fill(owner.firstName); + await this.ownerLastNameTexbox.fill(owner.lastName); + await this.ownerPhoneNumberTexbox.fill(owner.phoneNumber); + await this.ownerEmailTexbox.fill(owner.email); + + await this.ownerSaveButton.click(); + + // Wait for dialog to disappear + await expect(this.ownerAddDialog).toBeHidden(); + } + + // Actions + // ------- + + async addParcel() { + await this.parcelAddButton.click(); + } + + async setParcelType(parcelNumber: number, parcelType: ParcelType) { + await this.parcelBody(parcelNumber).getByRole('button', { name: parcelType }).click(); + } + + // Month uses 3-letter abbreviation (e.g., 'Apr') + async setDate(parcelNumber: number, year: string, month: string, day: string) { + await this.parcelBody(parcelNumber).getByRole('button', { name: 'Open calendar' }).click(); + // Can't have more than one datepicker open at once + // We just have to trust the correct one is still open + await this.page.getByRole('button', { name: year }).click(); + await this.page.getByRole('button').getByText(month).click(); + await this.page.getByRole('button', { name: day }).click(); + } + + async setIsFarm(parcelNumber: number, isFarm: boolean) { + await this.parcelBody(parcelNumber) + .getByRole('button', { name: isFarm ? 'Yes' : 'Else' }) + .click(); + } + + async uploadCertificateOfTitle(parcelNumber: number, path: string) { + const fileChooserPromise = this.page.waitForEvent('filechooser'); + await this.parcelBody(parcelNumber).getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path); + } + + async setOwnerType(type: OwnerType) { + await this.page.getByRole('button', { name: type }).click(); + } + + async uploadCorporateSummary(path: string) { + const fileChooserPromise = this.page.waitForEvent('filechooser'); + await this.ownerAddDialog.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path); + } + + async checkConfirmationCheckbox(parcelNumber: number) { + await this.confirmationCheckbox(parcelNumber).check(); + } + + // Locator Getters + // --------------- + + parcelHeading(parcelNumber: number): Locator { + return this.page.getByRole('button', { name: new RegExp(`^\s*Parcel #${parcelNumber}`) }); + } + + // parcelNumber is 1-indexed (i.e., the user-facing parcel number) + parcelBody(parcelNumber: number): Locator { + return this.page.getByRole('region', { name: `Parcel #${parcelNumber}`, includeHidden: true }); + } + + legalDescriptionTexbox(parcelNumber: number): Locator { + return this.parcelBody(parcelNumber).getByPlaceholder('Type legal description'); + } + + mapAreaTexbox(parcelNumber: number): Locator { + return this.parcelBody(parcelNumber).getByPlaceholder('Type parcel size'); + } + + pidTexbox(parcelNumber: number): Locator { + return this.parcelBody(parcelNumber).getByPlaceholder('Type PID'); + } + + pinTexbox(parcelNumber: number): Locator { + return this.parcelBody(parcelNumber).getByPlaceholder('Type PIN'); + } + + civicAddressTexbox(parcelNumber: number): Locator { + return this.parcelBody(parcelNumber).getByPlaceholder('Type Address'); + } + + nonCrownOwnerAddButton(parcelNumber: number): Locator { + return this.parcelBody(parcelNumber).getByRole('button', { name: 'Add new owner' }); + } + + crownOwnerAddButton(parcelNumber: number): Locator { + return this.parcelBody(parcelNumber).getByRole('button', { name: 'Add new gov contact' }); + } + + confirmationCheckbox(parcelNumber: number): Locator { + return this.parcelBody(parcelNumber).getByRole('checkbox', { + name: 'I confirm that the owner information provided above matches the current Certificate', + }); + } + + // State Getters + // ------------- + + async parcelType(parcelNumber: number): Promise { + const results = await Promise.all( + Object.values(ParcelType).map( + (type) => + new Promise<[ParcelType, boolean]>((resolve) => { + this.parcelBody(parcelNumber) + .getByRole('button', { name: type }) + .getAttribute('aria-pressed') + .then((isPressed) => { + resolve([type, isPressed === 'true']); + }); + }), + ), + ); + const [type, _] = results.filter(([_, isPressed]) => isPressed)[0]; + + return type; + } +} diff --git a/e2e/tests/pages/portal/portal-login-page.ts b/e2e/tests/pages/portal/portal-login-page.ts new file mode 100644 index 0000000000..85210ab4d1 --- /dev/null +++ b/e2e/tests/pages/portal/portal-login-page.ts @@ -0,0 +1,29 @@ +import { type Locator, type Page } from '@playwright/test'; + +export class PortalLoginPage { + readonly page: Page; + readonly loginButton: Locator; + readonly userIdTextbox: Locator; + readonly passwordTextbox: Locator; + readonly continueButton: Locator; + + constructor(page: Page) { + this.page = page; + this.loginButton = page.getByRole('button', { name: 'Portal Login' }); + // There is an error with the username label on BCeID page + this.userIdTextbox = page.getByRole('textbox').nth(0); + this.passwordTextbox = page.getByLabel('Password'); + this.continueButton = page.getByRole('button', { name: 'Continue' }); + } + + async goto() { + await this.page.goto('/'); + } + + async logIn(username: string, password: string) { + await this.loginButton.click(); + await this.userIdTextbox.fill(username); + await this.passwordTextbox.fill(password); + await this.continueButton.click(); + } +} diff --git a/e2e/tests/pages/portal/portal-steps-navigation.ts b/e2e/tests/pages/portal/portal-steps-navigation.ts new file mode 100644 index 0000000000..5d68a9b9ea --- /dev/null +++ b/e2e/tests/pages/portal/portal-steps-navigation.ts @@ -0,0 +1,57 @@ +import { type Locator, type Page } from '@playwright/test'; + +export class PortalStepsNavigation { + readonly page: Page; + readonly parcelsPageLink: Locator; + readonly otherParcelsPageLink: Locator; + readonly primaryContactPageLink: Locator; + readonly governmentPageLink: Locator; + readonly landUsePageLink: Locator; + readonly proposalPageLink: Locator; + readonly optionalAttachmentsPageLink: Locator; + readonly reviewAndSubmitPageLink: Locator; + + constructor(page: Page) { + this.page = page; + this.parcelsPageLink = page.getByTestId('steps').getByText('Parcel Details'); + this.otherParcelsPageLink = page.getByTestId('steps').getByText('Other Owned Parcels'); + this.primaryContactPageLink = page.getByTestId('steps').getByText('Primary Contact'); + this.governmentPageLink = page.getByTestId('steps').getByText('Government'); + this.landUsePageLink = page.getByTestId('steps').getByText('Land Use'); + this.proposalPageLink = page.getByTestId('steps').getByText('Proposal'); + this.optionalAttachmentsPageLink = page.getByTestId('steps').getByText('Upload Attachments'); + this.reviewAndSubmitPageLink = page.getByTestId('steps').getByText('Review & Submit'); + } + + async gotoParcelsPage() { + await this.parcelsPageLink.click(); + } + + async gotoOtherParcelsPage() { + await this.otherParcelsPageLink.click(); + } + + async gotoPrimaryContactPage() { + await this.primaryContactPageLink.click(); + } + + async gotoGovernmentPage() { + await this.governmentPageLink.click(); + } + + async gotoLandUsePage() { + await this.landUsePageLink.click(); + } + + async gotoProposalPage() { + await this.proposalPageLink.click(); + } + + async gotoOptionalAttachmentsPage() { + await this.optionalAttachmentsPageLink.click(); + } + + async gotoReviewAndSubmitPage() { + await this.reviewAndSubmitPageLink.click(); + } +} diff --git a/e2e/tests/pages/portal/primary-contact-page.ts b/e2e/tests/pages/portal/primary-contact-page.ts new file mode 100644 index 0000000000..904a039952 --- /dev/null +++ b/e2e/tests/pages/portal/primary-contact-page.ts @@ -0,0 +1,67 @@ +import { type Locator, type Page } from '@playwright/test'; + +export enum PrimaryContactType { + LandOwner = 'Land Owner', + ThirdParty = 'Third-Party Agent', +} + +export interface ThirdPartyContactDetails { + firstName: string; + lastName: string; + organization?: string; + phoneNumber: string; + email: string; +} + +export class PrimaryContactPage { + readonly page: Page; + readonly yesButton: Locator; + readonly noButton: Locator; + readonly firstNameTextbox: Locator; + readonly lastNameTextbox: Locator; + readonly organizationTextbox: Locator; + readonly phoneNumberTextbox: Locator; + readonly emailTextbox: Locator; + + constructor(page: Page) { + this.page = page; + this.yesButton = page.getByRole('button', { name: 'Yes', exact: true }); + this.noButton = page.getByRole('button', { name: 'No', exact: true }); + this.firstNameTextbox = page.getByPlaceholder('Enter First Name'); + this.lastNameTextbox = page.getByPlaceholder('Enter Last Name'); + this.organizationTextbox = page.getByPlaceholder('Enter Organization Name'); + this.phoneNumberTextbox = page.getByPlaceholder('(555) 555-5555'); + this.emailTextbox = page.getByPlaceholder('Enter Email'); + } + + async setPrimaryContactType(type: PrimaryContactType) { + if (type === PrimaryContactType.ThirdParty) { + await this.noButton.click(); + } else { + await this.yesButton.click(); + } + } + + async selectExistingContact(firstName: string, lastName: string) { + await this.page.getByRole('radio', { name: `${firstName} ${lastName}` }).check(); + } + + async fillThirdPartyContact(contact: ThirdPartyContactDetails) { + await this.firstNameTextbox.fill(contact.firstName); + await this.lastNameTextbox.fill(contact.lastName); + if (contact.organization !== undefined) { + await this.organizationTextbox.fill(contact.organization); + } + await this.phoneNumberTextbox.fill(contact.phoneNumber); + await this.emailTextbox.fill(contact.email); + } + + async uploadAuthorizationLetters(paths: string[]) { + for (const path of paths) { + const fileChooserPromise = this.page.waitForEvent('filechooser'); + await this.page.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path); + } + } +} diff --git a/e2e/tests/pages/portal/review-and-submit-page/government-section.ts b/e2e/tests/pages/portal/review-and-submit-page/government-section.ts new file mode 100644 index 0000000000..64827a0ae4 --- /dev/null +++ b/e2e/tests/pages/portal/review-and-submit-page/government-section.ts @@ -0,0 +1,15 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +export class GovernmentSection { + readonly page: Page; + readonly text: Locator; + + constructor(page: Page) { + this.page = page; + this.text = page.getByTestId('government-name'); + } + + async expectGovernment(government: string) { + await expect(this.text).toHaveText(government); + } +} diff --git a/e2e/tests/pages/portal/review-and-submit-page/land-use-section.ts b/e2e/tests/pages/portal/review-and-submit-page/land-use-section.ts new file mode 100644 index 0000000000..2e6068e1bb --- /dev/null +++ b/e2e/tests/pages/portal/review-and-submit-page/land-use-section.ts @@ -0,0 +1,27 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { type LandUseDetails } from '../land-use-page'; + +export class LandUseSection { + readonly page: Page; + readonly currentAgricultureText: Locator; + readonly improvementsText: Locator; + readonly otherUsesText: Locator; + + constructor(page: Page) { + this.page = page; + this.currentAgricultureText = page.getByTestId('parcels-agriculture-description'); + this.improvementsText = page.getByTestId('parcels-agriculture-improvement-description'); + this.otherUsesText = page.getByTestId('parcels-non-agriculture-description'); + } + + async expectLandUse(landUse: LandUseDetails) { + await expect(this.currentAgricultureText).toHaveText(landUse.currentAgriculture); + await expect(this.improvementsText).toHaveText(landUse.improvements); + await expect(this.otherUsesText).toHaveText(landUse.otherUses); + + for (const [direction, neighbouring] of landUse.neighbouringLandUse) { + await expect(this.page.getByTestId(`${direction}-land-use-type`)).toHaveText(neighbouring.type); + await expect(this.page.getByTestId(`${direction}-land-use-description`)).toHaveText(neighbouring.activity); + } + } +} diff --git a/e2e/tests/pages/portal/review-and-submit-page/optional-documents-section.ts b/e2e/tests/pages/portal/review-and-submit-page/optional-documents-section.ts new file mode 100644 index 0000000000..290cea32b0 --- /dev/null +++ b/e2e/tests/pages/portal/review-and-submit-page/optional-documents-section.ts @@ -0,0 +1,28 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { OptionalAttachment } from '../optional-attachments-page'; + +export class OptionalDocumentsSection { + readonly page: Page; + readonly fileNameTexts: Locator; + readonly typeTexts: Locator; + readonly descriptionTexts: Locator; + + constructor(page: Page) { + this.page = page; + this.fileNameTexts = page.getByTestId(`optional-document-file-name`); + this.typeTexts = page.getByTestId(`optional-document-type`); + this.descriptionTexts = page.getByTestId(`optional-document-description`); + } + + async expectAttachments(attachments: OptionalAttachment[]) { + for (const attachment of attachments) { + await expect(this.fileNameTexts.filter({ hasText: this.fileName(attachment.path) })).toBeVisible(); + await expect(this.typeTexts.filter({ hasText: attachment.type })).toBeVisible(); + await expect(this.descriptionTexts.filter({ hasText: attachment.description })).toBeVisible(); + } + } + + fileName(path: string): string { + return path.substring(path.lastIndexOf('/') + 1); + } +} diff --git a/e2e/tests/pages/portal/review-and-submit-page/other-owned-parcels-section.ts b/e2e/tests/pages/portal/review-and-submit-page/other-owned-parcels-section.ts new file mode 100644 index 0000000000..3773110006 --- /dev/null +++ b/e2e/tests/pages/portal/review-and-submit-page/other-owned-parcels-section.ts @@ -0,0 +1,21 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +export class OtherOwnedParcelsSection { + readonly page: Page; + readonly hasOtherParcelsText: Locator; + readonly descriptionText: Locator; + + constructor(page: Page) { + this.page = page; + this.hasOtherParcelsText = page.getByTestId('has-other-parcels'); + this.descriptionText = page.getByTestId('other-parcels-description'); + } + + async expectHasOtherParcels(hasOtherParcels: boolean) { + await expect(this.hasOtherParcelsText).toHaveText(hasOtherParcels ? 'Yes' : 'No'); + } + + async expectDescription(description: string) { + await expect(this.descriptionText).toHaveText(description); + } +} diff --git a/e2e/tests/pages/portal/review-and-submit-page/parcels-section.ts b/e2e/tests/pages/portal/review-and-submit-page/parcels-section.ts new file mode 100644 index 0000000000..90d9a68d57 --- /dev/null +++ b/e2e/tests/pages/portal/review-and-submit-page/parcels-section.ts @@ -0,0 +1,130 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { type ParcelDetails, type ParcelOwnerDetails } from '../parcels-page'; + +export class ParcelsSection { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async expectParcels(parcels: ParcelDetails[]) { + for (const [parcel, i] of parcels.map((parcel, i): [ParcelDetails, number] => [parcel, i])) { + const parcelNumber = i + 1; + + await this.expectParcelDetails(parcelNumber, parcel); + await this.expectParcelOwners(parcelNumber, parcel.owners); + } + } + + async expectParcelDetails(parcelNumber: number, parcel: ParcelDetails) { + await expect(this.typeText(parcelNumber)).toHaveText(parcel.type); + await expect(this.legalDescriptionText(parcelNumber)).toHaveText(parcel.legalDescription); + await expect(this.mapAreaText(parcelNumber)).toHaveText(`${parcel.mapArea} ha`); + if (parcel.pid !== undefined) { + await expect(this.pidText(parcelNumber)).toHaveText(parcel.pid); + } + if (parcel.pin !== undefined) { + await expect(this.pinText(parcelNumber)).toHaveText(parcel.pin); + } + if (parcel.month !== undefined && parcel.day !== undefined && parcel.year !== undefined) { + await expect(this.purchaseDateText(parcelNumber)).toHaveText(`${parcel.month} ${parcel.day}, ${parcel.year}`); + } + await expect(this.isFarmText(parcelNumber)).toHaveText(parcel.isFarm ? 'Yes' : 'No'); + await expect(this.civicAddressText(parcelNumber)).toHaveText(parcel.civicAddress); + await expect(this.certificateOfTitleText(parcelNumber)).toHaveText(this.fileName(parcel.certificateOfTitlePath)); + await expect(this.confirmationText(parcelNumber)).toHaveText(parcel.isConfirmed ? 'Yes' : 'No'); + } + + async expectParcelOwners(parcelNumber: number, owners: ParcelOwnerDetails[]) { + const nameTexts = this.ownerNameTexts(parcelNumber); + const organizationTexts = this.ownerOrganizationTexts(parcelNumber); + const phoneNumberTexts = this.ownerPhoneNumberTexts(parcelNumber); + const emailTexts = this.ownerEmailTexts(parcelNumber); + const corporateSummaryTexts = this.ownerCorporateSummaryTexts(parcelNumber); + + for (const owner of owners) { + await expect(nameTexts.filter({ hasText: `${owner.firstName} ${owner.lastName}` })).toBeVisible(); + if (owner.organization !== undefined) { + await expect(organizationTexts.filter({ hasText: owner.organization })).toBeVisible(); + } + await expect(phoneNumberTexts.filter({ hasText: owner.phoneNumber })).toBeVisible(); + await expect(emailTexts.filter({ hasText: owner.email })).toBeVisible(); + if (owner.corporateSummaryPath !== undefined) { + await expect( + corporateSummaryTexts.filter({ hasText: this.fileName(owner.corporateSummaryPath) }), + ).toBeVisible(); + } + } + } + + // Locator Getters + // --------------- + + typeText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-type`); + } + + legalDescriptionText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-legal-description`); + } + + mapAreaText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-map-area`); + } + + pidText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-pid`); + } + + pinText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-pin`); + } + + purchaseDateText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-purchase-date`); + } + + isFarmText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-is-farm`); + } + + civicAddressText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-civic-address`); + } + + certificateOfTitleText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-certificate-of-title`); + } + + confirmationText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-is-confirmed-by-applicant`); + } + + ownerNameTexts(parcelNumber: number) { + return this.page.getByTestId(`parcel-${parcelNumber}-owner-name`); + } + + ownerOrganizationTexts(parcelNumber: number) { + return this.page.getByTestId(`parcel-${parcelNumber}-owner-organization`); + } + + ownerPhoneNumberTexts(parcelNumber: number) { + return this.page.getByTestId(`parcel-${parcelNumber}-owner-phone-number`); + } + + ownerEmailTexts(parcelNumber: number) { + return this.page.getByTestId(`parcel-${parcelNumber}-owner-email`); + } + + ownerCorporateSummaryTexts(parcelNumber: number) { + return this.page.getByTestId(`parcel-${parcelNumber}-owner-corporate-summary`); + } + + // Utils + // ----- + + fileName(path: string): string { + return path.substring(path.lastIndexOf('/') + 1); + } +} diff --git a/e2e/tests/pages/portal/review-and-submit-page/primary-contact-section.ts b/e2e/tests/pages/portal/review-and-submit-page/primary-contact-section.ts new file mode 100644 index 0000000000..f7ca37efeb --- /dev/null +++ b/e2e/tests/pages/portal/review-and-submit-page/primary-contact-section.ts @@ -0,0 +1,45 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { PrimaryContactType, type ThirdPartyContactDetails } from '../primary-contact-page'; + +export class PrimaryContactSection { + readonly page: Page; + readonly typeText: Locator; + readonly firstNameText: Locator; + readonly lastNameText: Locator; + readonly organizationText: Locator; + readonly phoneNumberText: Locator; + readonly emailText: Locator; + readonly authorizationLetterTexts: Locator; + + constructor(page: Page) { + this.page = page; + this.typeText = page.getByTestId('primary-contact-type'); + this.firstNameText = page.getByTestId('primary-contact-first-name'); + this.lastNameText = page.getByTestId('primary-contact-last-name'); + this.organizationText = page.getByTestId('primary-contact-organization'); + this.phoneNumberText = page.getByTestId('primary-contact-phone-number'); + this.emailText = page.getByTestId('primary-contact-email'); + this.authorizationLetterTexts = page.getByTestId('authorization-letter'); + } + + async expectThirdPartyContact(contact: ThirdPartyContactDetails) { + await expect(this.typeText).toHaveText(PrimaryContactType.ThirdParty); + await expect(this.firstNameText).toHaveText(contact.firstName); + await expect(this.lastNameText).toHaveText(contact.lastName); + if (contact.organization !== undefined) { + await expect(this.organizationText).toHaveText(contact.organization); + } + await expect(this.phoneNumberText).toHaveText(contact.phoneNumber); + await expect(this.emailText).toHaveText(contact.email); + } + + async expectAuthorizationLetters(authorizationLetterPaths: string[]) { + for (const path of authorizationLetterPaths) { + await expect(this.authorizationLetterTexts.filter({ hasText: this.fileName(path) })).toBeVisible(); + } + } + + fileName(path: string): string { + return path.substring(path.lastIndexOf('/') + 1); + } +} diff --git a/e2e/tests/pages/portal/review-and-submit-page/review-and-submit-page.ts b/e2e/tests/pages/portal/review-and-submit-page/review-and-submit-page.ts new file mode 100644 index 0000000000..ed40893fe3 --- /dev/null +++ b/e2e/tests/pages/portal/review-and-submit-page/review-and-submit-page.ts @@ -0,0 +1,64 @@ +import { type Locator, type Page } from '@playwright/test'; +import { ParcelsSection } from './parcels-section'; +import { PrimaryContactSection } from './primary-contact-section'; +import { OtherOwnedParcelsSection } from './other-owned-parcels-section'; +import { GovernmentSection } from './government-section'; +import { LandUseSection } from './land-use-section'; +import { TURProposalSection } from './tur-proposal-section'; +import { OptionalDocumentsSection } from './optional-documents-section'; + +export class ReviewAndSubmitPage { + readonly page: Page; + + // Locators + readonly submitButton: Locator; + readonly informationUseCheckbox: Locator; + readonly informationCorrectnessCheckbox: Locator; + readonly acceptVerificationCheckbox: Locator; + readonly submitDialog: Locator; + readonly finalSubmitButton: Locator; + + // Sections + readonly parcelsSection: ParcelsSection; + readonly otherOwnedParcelsSection: OtherOwnedParcelsSection; + readonly primaryContactSection: PrimaryContactSection; + readonly governmentSection: GovernmentSection; + readonly landUseSection: LandUseSection; + readonly turProposalSection: TURProposalSection; + readonly optionalDocumentsSection: OptionalDocumentsSection; + + constructor(page: Page) { + this.page = page; + + // Locators + this.submitButton = page.getByRole('button', { name: 'Submit' }); + this.informationUseCheckbox = page.getByRole('checkbox', { + name: 'I/we consent to the use of the information provided in the application and all supporting documents to process the application in accordance with the Agricultural Land Commission Act, the Agricultural Land Reserve General Regulation, and the Agricultural Land Reserve Use Regulation.', + }); + this.informationCorrectnessCheckbox = page.getByRole('checkbox', { + name: 'I/we declare that the information provided in the application and all the supporting documents are, to the best of my/our knowledge, true and correct.', + }); + this.acceptVerificationCheckbox = page.getByRole('checkbox', { + name: 'I/we understand that the Agricultural Land Commission will take the steps necessary to confirm the accuracy of the information and documents provided. This information, excluding phone numbers and emails, will be available for review by any member of the public once received by the ALC.', + }); + this.submitDialog = page.getByRole('dialog').filter({ hasText: 'Submit Application' }); + this.finalSubmitButton = this.submitDialog.getByRole('button', { name: 'Submit' }); + + // Sections + this.parcelsSection = new ParcelsSection(page); + this.otherOwnedParcelsSection = new OtherOwnedParcelsSection(page); + this.primaryContactSection = new PrimaryContactSection(page); + this.governmentSection = new GovernmentSection(page); + this.landUseSection = new LandUseSection(page); + this.turProposalSection = new TURProposalSection(page); + this.optionalDocumentsSection = new OptionalDocumentsSection(page); + } + + async submit() { + await this.submitButton.click(); + await this.informationUseCheckbox.check(); + await this.informationCorrectnessCheckbox.check(); + await this.acceptVerificationCheckbox.check(); + await this.finalSubmitButton.click(); + } +} diff --git a/e2e/tests/pages/portal/review-and-submit-page/tur-proposal-section.ts b/e2e/tests/pages/portal/review-and-submit-page/tur-proposal-section.ts new file mode 100644 index 0000000000..1d9f8b4725 --- /dev/null +++ b/e2e/tests/pages/portal/review-and-submit-page/tur-proposal-section.ts @@ -0,0 +1,41 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { TURProposal } from '../tur-proposal-page'; + +export class TURProposalSection { + readonly page: Page; + readonly purposeText: Locator; + readonly activitiesText: Locator; + readonly stepsToReduceImpactText: Locator; + readonly alternativeLandText: Locator; + readonly totalAreaText: Locator; + readonly allOwnersNotifiedText: Locator; + readonly proofOfServingNoticeText: Locator; + readonly proposalMapText: Locator; + + constructor(page: Page) { + this.page = page; + this.purposeText = page.getByTestId('tur-purpose'); + this.activitiesText = page.getByTestId('tur-agricultural-activities'); + this.stepsToReduceImpactText = page.getByTestId('tur-reduce-negative-impacts'); + this.alternativeLandText = page.getByTestId('tur-outside-lands'); + this.totalAreaText = page.getByTestId('tur-total-corridor-area'); + this.allOwnersNotifiedText = page.getByTestId('tur-all-owners-notified'); + this.proofOfServingNoticeText = page.getByTestId('tur-proof-of-serving-notice'); + this.proposalMapText = page.getByTestId('tur-proposal-map'); + } + + async expectProposal(proposal: TURProposal) { + await expect(this.purposeText).toHaveText(proposal.purpose); + await expect(this.activitiesText).toHaveText(proposal.activities); + await expect(this.stepsToReduceImpactText).toHaveText(proposal.stepsToReduceImpact); + await expect(this.alternativeLandText).toHaveText(proposal.alternativeLand); + await expect(this.totalAreaText).toHaveText(`${proposal.totalArea} ha`); + await expect(this.allOwnersNotifiedText).toHaveText(proposal.isConfirmed ? 'Yes' : 'No'); + await expect(this.proofOfServingNoticeText).toHaveText(this.fileName(proposal.proofOfServingNoticePath)); + await expect(this.proposalMapText).toHaveText(this.fileName(proposal.proposalMapPath)); + } + + fileName(path: string): string { + return path.substring(path.lastIndexOf('/') + 1); + } +} diff --git a/e2e/tests/pages/portal/submission-success-page.ts b/e2e/tests/pages/portal/submission-success-page.ts new file mode 100644 index 0000000000..378ce1143a --- /dev/null +++ b/e2e/tests/pages/portal/submission-success-page.ts @@ -0,0 +1,26 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +export class SubmissionSuccessPage { + readonly page: Page; + readonly heading: Locator; + + constructor(page: Page) { + this.page = page; + this.heading = page.getByRole('heading', { name: 'Application ID' }); + } + + async fileId(): Promise { + const headingText = await this.heading.textContent(); + + await expect(headingText).not.toBeNull(); + + const matches = headingText!.match(/(?<=Application ID: )\d*/); + + await expect(matches).not.toBeNull(); + + const fileId: string = matches![0]; + + // Should never return empty string, but necessary to appease TypeScript + return fileId; + } +} diff --git a/e2e/tests/pages/portal/tur-proposal-page.ts b/e2e/tests/pages/portal/tur-proposal-page.ts new file mode 100644 index 0000000000..db59d66679 --- /dev/null +++ b/e2e/tests/pages/portal/tur-proposal-page.ts @@ -0,0 +1,65 @@ +import { type Locator, type Page } from '@playwright/test'; + +export interface TURProposal { + purpose: string; + activities: string; + stepsToReduceImpact: string; + alternativeLand: string; + totalArea: string; + isConfirmed: boolean; + proofOfServingNoticePath: string; + proposalMapPath: string; +} + +export class TURProposalPage { + readonly page: Page; + readonly purposeTextbox: Locator; + readonly activitiesTextbox: Locator; + readonly stepsToReduceImpactTextbox: Locator; + readonly alternativeLandTextbox: Locator; + readonly totalAreaTextbox: Locator; + readonly confirmationCheckbox: Locator; + + constructor(page: Page) { + this.page = page; + this.purposeTextbox = page.getByLabel('What is the purpose of the proposal?'); + this.activitiesTextbox = page.getByLabel( + 'Specify any agricultural activities such as livestock operations, greenhouses or horticultural activities in proximity to the proposal.', + ); + this.stepsToReduceImpactTextbox = page.getByLabel( + 'What steps will you take to reduce potential negative impacts on surrounding agricultural lands?', + ); + this.alternativeLandTextbox = page.getByLabel('Could this proposal be accommodated on lands outside of the ALR?'); + this.totalAreaTextbox = page.getByPlaceholder('Type total area'); + this.confirmationCheckbox = page.getByText( + 'I confirm that all affected property owners with land in the ALR have been notif', + ); + } + + async fill(proposal: TURProposal) { + await this.purposeTextbox.fill(proposal.purpose); + await this.activitiesTextbox.fill(proposal.activities); + await this.stepsToReduceImpactTextbox.fill(proposal.stepsToReduceImpact); + await this.alternativeLandTextbox.fill(proposal.alternativeLand); + await this.totalAreaTextbox.fill(proposal.totalArea); + if (proposal.isConfirmed) { + await this.confirmationCheckbox.check(); + } + await this.uploadProofOfServingNotice(proposal.proofOfServingNoticePath); + await this.uploadProposalMap(proposal.proposalMapPath); + } + + async uploadProofOfServingNotice(path: string) { + const fileChooserPromise = this.page.waitForEvent('filechooser'); + await this.page.getByTestId('proof-of-serving-notice-filechooser').click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path); + } + + async uploadProposalMap(path: string) { + const fileChooserPromise = this.page.waitForEvent('filechooser'); + await this.page.getByTestId('proposal-map-filechooser').click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path); + } +} diff --git a/e2e/tests/tur.spec.ts b/e2e/tests/tur.spec.ts index 9281edc66a..b2f9cbfcec 100644 --- a/e2e/tests/tur.spec.ts +++ b/e2e/tests/tur.spec.ts @@ -1,8 +1,122 @@ -import { test, expect } from '@playwright/test'; -import { PortalLoginPage } from './pages/portal-login-page'; -import { ApplicationType, InboxPage } from './pages/inbox-page'; +import { test } from '@playwright/test'; +import { PortalLoginPage } from './pages/portal/portal-login-page'; +import { ApplicationType, InboxPage } from './pages/portal/inbox-page'; +import { PortalStepsNavigation } from './pages/portal/portal-steps-navigation'; +import { OwnerType, ParcelType, ParcelsPage } from './pages/portal/parcels-page'; +import { OtherParcelsPage } from './pages/portal/other-parcels-page'; +import { PrimaryContactPage, PrimaryContactType } from './pages/portal/primary-contact-page'; +import { GovernmentPage } from './pages/portal/government-page'; +import { Direction, LandUsePage, LandUseType } from './pages/portal/land-use-page'; +import { TURProposalPage } from './pages/portal/tur-proposal-page'; +import { OptionalAttachmentType, OptionalAttachmentsPage } from './pages/portal/optional-attachments-page'; +import { ReviewAndSubmitPage } from './pages/portal/review-and-submit-page/review-and-submit-page'; +import { SubmissionSuccessPage } from './pages/portal/submission-success-page'; test('TUR', async ({ browser }) => { + const parcels = [ + { + type: ParcelType.FeeSimple, + legalDescription: 'Legal description 1', + mapArea: '1', + pid: '111-111-111', + year: '2014', + month: 'Apr', + day: '21', + isFarm: true, + civicAddress: '123 Street Rd', + certificateOfTitlePath: 'data/temp.txt', + isConfirmed: true, + owners: [ + { + type: OwnerType.Individual, + firstName: 'John', + lastName: 'Doe', + phoneNumber: '(111) 111-1111', + email: '1@1', + }, + { + type: OwnerType.Organization, + organization: 'Company X', + corporateSummaryPath: 'data/temp.txt', + firstName: 'Jane', + lastName: 'Doe', + phoneNumber: '(222) 222-2222', + email: '2@2', + }, + ], + }, + ]; + const otherParcelsDescription = 'Other parcels description'; + const hasOtherParcels = true; + const primaryContactType = PrimaryContactType.ThirdParty; + const thirdPartPrimaryContact = { + firstName: 'Person', + lastName: 'Human', + phoneNumber: '(555) 555-5555', + email: '1@1', + }; + const authorizationLetterPaths = ['data/temp.txt', 'data/temp2.txt']; + const government = 'Peace River Regional District'; + const landUse = { + currentAgriculture: 'Current agriculture', + improvements: 'Improvements', + otherUses: 'Other uses', + neighbouringLandUse: new Map([ + [ + Direction.North, + { + type: LandUseType.Agricultural, + activity: 'Doing agriculture', + }, + ], + [ + Direction.East, + { + type: LandUseType.Civic, + activity: 'Doing agriculture', + }, + ], + [ + Direction.South, + { + type: LandUseType.Commercial, + activity: 'Doing agriculture', + }, + ], + [ + Direction.West, + { + type: LandUseType.Industrial, + activity: 'Doing agriculture', + }, + ], + ]), + }; + const turProposal = { + purpose: 'To do stuff', + activities: 'Doing stuff', + stepsToReduceImpact: 'Steps 1, 2, and 3', + alternativeLand: 'This land over here', + totalArea: '1', + isConfirmed: true, + proofOfServingNoticePath: 'data/temp.txt', + proposalMapPath: 'data/temp2.txt', + }; + const optionalAttachments = [ + { + path: 'data/temp.txt', + type: OptionalAttachmentType.SitePhoto, + description: 'Some site photo', + }, + { + path: 'data/temp2.txt', + type: OptionalAttachmentType.ProfessionalReport, + description: 'Some professional report', + }, + ]; + + let submittedFileId: string; + const context = await browser.newContext({ baseURL: process.env.PORTAL_BASE_URL }); const page = await context.newPage(); @@ -13,196 +127,58 @@ test('TUR', async ({ browser }) => { const inbox = new InboxPage(page); await inbox.createApplication(ApplicationType.TUR); - // Step 1a: Parcels - await page.getByRole('button', { name: 'Fee Simple' }).click(); - await page.getByPlaceholder('Type legal description').fill('Parcel description'); - await page.getByPlaceholder('Type parcel size').fill('1'); - await page.getByPlaceholder('Type PID').fill('111-111-111'); - await page.getByRole('button', { name: 'Open calendar' }).click(); - await page.getByText('2014').click(); - await page.getByText('Apr').click(); - await page.getByText('23').click(); - await page.getByText('Yes').click(); - await page.getByPlaceholder('Type Address').fill('123 Street Rd'); - - // Upload - const titleFileChooserPromise = page.waitForEvent('filechooser'); - await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); - const titleFileChooser = await titleFileChooserPromise; - titleFileChooser.setFiles('data/temp.txt'); - - // Step 1b: Parcel Owners - await page.getByRole('button', { name: 'Add new owner' }).click(); - await page.getByRole('button', { name: 'Individual' }).click(); - await page.getByPlaceholder('Enter First Name').fill('1'); - await page.getByPlaceholder('Enter Last Name').fill('1'); - await page.getByPlaceholder('(555) 555-5555').fill('(111) 111-11111'); - await page.getByPlaceholder('Enter Email').fill('1@1'); - await page.getByRole('button', { name: 'Add' }).click(); - await page.getByText('I confirm that the owner information provided above matches the current Certific').click(); - await page.getByText('Other Owned Parcels', { exact: true }).click(); - - // Step 2: Other Parcels - await page.getByRole('button', { name: 'Yes' }).click(); - await page - .getByLabel('Describe the other parcels including their location, who owns or leases them, and their use.') - .fill('Other parcels'); - await page.getByText('Primary Contact', { exact: true }).click(); - - // Step 3: Primary Contact - await page.getByRole('button', { name: 'Yes' }).click(); - await page.getByLabel('1 1').check(); - await page.getByText('Government', { exact: true }).click(); - - // Step 4: Government - await page.getByPlaceholder('Type government').click(); - await page.getByPlaceholder('Type government').fill('peace'); - await page.getByText('Peace River Regional District').click(); - await page.getByText('Land Use').click(); - - // Step 5: Land Use - await page.getByLabel('Describe all agriculture that currently takes place on the parcel(s).').fill('This'); - await page.getByLabel('Describe all agricultural improvements made to the parcel(s).').fill('That'); - await page.getByLabel('Describe all other uses that currently take place on the parcel(s).').fill('The other'); - - // North - await page.getByPlaceholder('Main Land Use Type').nth(0).click(); - await page.locator('#northLandUseType-panel').getByRole('option', { name: 'Agricultural / Farm' }).click(); - await page.getByRole('textbox', { name: 'North land use type description' }).fill('1'); - - // East - await page.getByPlaceholder('Main Land Use Type').nth(1).click(); - await page.locator('#eastLandUseType-panel').getByRole('option', { name: 'Civic / Institutional' }).click(); - await page.getByRole('textbox', { name: 'East land use type description' }).fill('1'); - - // South - await page.getByPlaceholder('Main Land Use Type').nth(2).click(); - await page.locator('#southLandUseType-panel').getByRole('option', { name: 'Commercial / Retail' }).click(); - await page.getByRole('textbox', { name: 'South land use type description' }).fill('1'); - - // West - await page.getByPlaceholder('Main Land Use Type').nth(3).click(); - await page.locator('#westLandUseType-panel').getByRole('option', { name: 'Industrial' }).click(); - await page.getByRole('textbox', { name: 'West land use type description' }).fill('1'); - - await page.getByText('Proposal', { exact: true }).click(); - - // Step 6: Proposal - await page.getByLabel('What is the purpose of the proposal?').fill('This'); - await page - .getByLabel( - 'Specify any agricultural activities such as livestock operations, greenhouses or horticultural activities in proximity to the proposal.', - ) - .fill('That'); - await page - .getByLabel('What steps will you take to reduce potential negative impacts on surrounding agricultural lands?') - .fill('The other'); - await page.getByLabel('Could this proposal be accommodated on lands outside of the ALR?').fill('And another'); - await page.getByPlaceholder('Type total area').fill('1'); - await page.getByText('I confirm that all affected property owners with land in the ALR have been notif').click(); - - // File upload - const proofOfServiceNoticeFileChooserPromise = page.waitForEvent('filechooser'); - await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).nth(0).click(); - const proofOfServiceNoticeFileChooser = await proofOfServiceNoticeFileChooserPromise; - proofOfServiceNoticeFileChooser.setFiles('data/temp.txt'); - - // File upload - const proposalMapFileChooserPromise = page.waitForEvent('filechooser'); - await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).nth(1).click(); - const proposalMapFileChooser = await proposalMapFileChooserPromise; - proposalMapFileChooser.setFiles('data/temp.txt'); - - await page.getByText('Upload Attachments').click(); - - // Step 7: Optional attachments - // File upload first file - const optionalFile1ChooserPromise = page.waitForEvent('filechooser'); - await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); - const optionalFile1Chooser = await optionalFile1ChooserPromise; - optionalFile1Chooser.setFiles('data/temp.txt'); - await page.getByPlaceholder('Select a type').nth(0).click(); - await page.getByText('Professional Report').click(); - await page.getByPlaceholder('Type description').nth(0).fill('Desc'); - - // File upload second file - const optionalFile2ChooserPromise = page.waitForEvent('filechooser'); - await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); - const optionalFile2Chooser = await optionalFile2ChooserPromise; - optionalFile2Chooser.setFiles('data/temp.txt'); - await page.getByPlaceholder('Select a type').nth(1).click(); - await page.getByText('Site Photo').click(); - await page.getByPlaceholder('Type description').nth(1).fill('Desc'); - - await page.getByText('Review & Submit').click(); - - // Step 8: Review - // 1. Parcels - // Parcel 1 - await expect(page.getByTestId('parcel-0-type')).toHaveText('Fee Simple'); - await expect(page.getByTestId('parcel-0-legal-description')).toHaveText('Parcel description'); - await expect(page.getByTestId('parcel-0-map-area')).toHaveText('1 ha'); - await expect(page.getByTestId('parcel-0-pid')).toHaveText('111-111-111'); - await expect(page.getByTestId('parcel-0-purchase-date')).toHaveText('Apr 23, 2014'); - await expect(page.getByTestId('parcel-0-is-farm')).toHaveText('Yes'); - await expect(page.getByTestId('parcel-0-civic-address')).toHaveText('123 Street Rd'); - await expect(page.getByTestId('parcel-0-certificate-of-title')).toHaveText('temp.txt'); - - // Owners - await expect(page.getByTestId('parcel-0-owner-0-name')).toHaveText('1 1'); - await expect(page.getByTestId('parcel-0-owner-0-organization')).toHaveText('No Data'); - await expect(page.getByTestId('parcel-0-owner-0-phone-number')).toHaveText('(111) 111-1111'); - await expect(page.getByTestId('parcel-0-owner-0-email')).toHaveText('1@1'); - await expect(page.getByTestId('parcel-0-owner-0-corporate-summary')).toHaveText('Not Applicable'); - - await expect(page.getByTestId('parcel-0-is-confirmed-by-applicant')).toHaveText('Yes'); - - // 2. Other Parcels - await expect(page.getByTestId('has-other-parcels')).toHaveText('Yes'); - await expect(page.getByTestId('other-parcels-description')).toHaveText('Other parcels'); - - // 3. Primary Contact - await expect(page.getByTestId('primary-contact-type')).toHaveText('Land Owner'); - await expect(page.getByTestId('primary-contact-first-name')).toHaveText('1'); - await expect(page.getByTestId('primary-contact-last-name')).toHaveText('1'); - await expect(page.getByTestId('primary-contact-organization')).toHaveText('No Data'); - await expect(page.getByTestId('primary-contact-phone-number')).toHaveText('(111) 111-1111'); - await expect(page.getByTestId('primary-contact-email')).toHaveText('1@1'); - - // 4. Government - await expect(page.getByTestId('government-name')).toHaveText('Peace River Regional District'); - - // 5. Land Use - await expect(page.getByTestId('parcels-agriculture-description')).toHaveText('This'); - await expect(page.getByTestId('parcels-agriculture-improvement-description')).toHaveText('That'); - await expect(page.getByTestId('parcels-non-agriculture-description')).toHaveText('The other'); - await expect(page.getByTestId('north-land-use-type')).toHaveText('Agricultural / Farm'); - await expect(page.getByTestId('north-land-use-description')).toHaveText('1'); - await expect(page.getByTestId('east-land-use-type')).toHaveText('Civic / Institutional'); - await expect(page.getByTestId('east-land-use-description')).toHaveText('1'); - await expect(page.getByTestId('south-land-use-type')).toHaveText('Commercial / Retail'); - await expect(page.getByTestId('south-land-use-description')).toHaveText('1'); - await expect(page.getByTestId('west-land-use-type')).toHaveText('Industrial'); - await expect(page.getByTestId('west-land-use-description')).toHaveText('1'); - - // 6. Proposal - await expect(page.getByTestId('tur-purpose')).toHaveText('This'); - await expect(page.getByTestId('tur-agricultural-activities')).toHaveText('That'); - await expect(page.getByTestId('tur-reduce-negative-impacts')).toHaveText('The other'); - await expect(page.getByTestId('tur-outside-lands')).toHaveText('And another'); - await expect(page.getByTestId('tur-total-corridor-area')).toHaveText('1 ha'); - await expect(page.getByTestId('tur-all-owners-notified')).toHaveText('Yes'); - await expect(page.getByTestId('tur-proof-of-serving-notice')).toHaveText('temp.txt'); - await expect(page.getByTestId('tur-proposal-map')).toHaveText('temp.txt'); - - // 7. Optional Documents - // Doc 1 - await expect(page.getByTestId('optional-document-0-file-name')).toHaveText('temp.txt'); - await expect(page.getByTestId('optional-document-0-type')).toHaveText('Professional Report'); - await expect(page.getByTestId('optional-document-0-description')).toHaveText('Desc'); - - // Doc 2 - await expect(page.getByTestId('optional-document-1-file-name')).toHaveText('temp.txt'); - await expect(page.getByTestId('optional-document-1-type')).toHaveText('Site Photo'); - await expect(page.getByTestId('optional-document-1-description')).toHaveText('Desc'); + const portalStepsNavigation = new PortalStepsNavigation(page); + + const parcelsPage = new ParcelsPage(page); + await parcelsPage.fill(parcels); + + await portalStepsNavigation.gotoOtherParcelsPage(); + + const otherParcelsPage = new OtherParcelsPage(page); + await otherParcelsPage.setHasOtherParcels(hasOtherParcels); + await otherParcelsPage.fillDescription(otherParcelsDescription); + + await portalStepsNavigation.gotoPrimaryContactPage(); + + const primaryContactPage = new PrimaryContactPage(page); + await primaryContactPage.setPrimaryContactType(primaryContactType); + await primaryContactPage.fillThirdPartyContact(thirdPartPrimaryContact); + await primaryContactPage.uploadAuthorizationLetters(authorizationLetterPaths); + + await portalStepsNavigation.gotoGovernmentPage(); + + const governmentPage = new GovernmentPage(page); + await governmentPage.fill(government); + + await portalStepsNavigation.gotoLandUsePage(); + + const landUsePage = new LandUsePage(page); + await landUsePage.fill(landUse); + + await portalStepsNavigation.gotoProposalPage(); + + const turProposalPage = new TURProposalPage(page); + await turProposalPage.fill(turProposal); + + await portalStepsNavigation.gotoOptionalAttachmentsPage(); + + const optionalAttachmentsPage = new OptionalAttachmentsPage(page); + await optionalAttachmentsPage.addAttachments(optionalAttachments); + + await portalStepsNavigation.gotoReviewAndSubmitPage(); + + const reviewAndSubmitPage = new ReviewAndSubmitPage(page); + await reviewAndSubmitPage.parcelsSection.expectParcels(parcels); + await reviewAndSubmitPage.otherOwnedParcelsSection.expectHasOtherParcels(hasOtherParcels); + await reviewAndSubmitPage.otherOwnedParcelsSection.expectDescription(otherParcelsDescription); + await reviewAndSubmitPage.primaryContactSection.expectThirdPartyContact(thirdPartPrimaryContact); + await reviewAndSubmitPage.primaryContactSection.expectAuthorizationLetters(authorizationLetterPaths); + await reviewAndSubmitPage.governmentSection.expectGovernment(government); + await reviewAndSubmitPage.landUseSection.expectLandUse(landUse); + await reviewAndSubmitPage.turProposalSection.expectProposal(turProposal); + await reviewAndSubmitPage.optionalDocumentsSection.expectAttachments(optionalAttachments); + await reviewAndSubmitPage.submit(); + + const submissionSuccessPage = new SubmissionSuccessPage(page); + submittedFileId = await submissionSuccessPage.fileId(); }); diff --git a/portal-frontend/src/app/features/applications/application-details/application-details.component.html b/portal-frontend/src/app/features/applications/application-details/application-details.component.html index c633ffe485..5a11d6f5db 100644 --- a/portal-frontend/src/app/features/applications/application-details/application-details.component.html +++ b/portal-frontend/src/app/features/applications/application-details/application-details.component.html @@ -15,8 +15,8 @@

2. Other Owned Parcels

{{ - applicationSubmission.hasOtherParcelsInCommunity ? 'Yes' : 'No' - }} + applicationSubmission.hasOtherParcelsInCommunity ? 'Yes' : 'No' + }} 3. Primary Contact
Organization (optional)Organization (optional) Ministry/Department Responsible Department @@ -71,12 +71,11 @@

3. Primary Contact

Phone
- {{ primaryContact?.phoneNumber ?? '' | mask : '(000) 000-0000' }} + {{ primaryContact?.phoneNumber ?? '' | mask: '(000) 000-0000' }} Invalid Format - + >Invalid Format +
Email
@@ -86,13 +85,13 @@

3. Primary Contact

Authorization Letter(s)
-
+
Authorization letters are not required, please remove them @@ -115,8 +114,8 @@

4. Government

This local/First Nation government is not set up with the ALC Portal to receive submissions. You can continue to fill out the form but you will be unable to submit. Please contact the ALC directly as soon as possible:  ALC.Portal@gov.bc.ca / 236-468-3342 + >236-468-3342 Type
Description
- -
+ + -
+
{{ file.type?.label }}
-
+
{{ file.description }}
diff --git a/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.html b/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.html index 9f21e051f6..4a7d1ca754 100644 --- a/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.html +++ b/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.html @@ -25,13 +25,13 @@

Parcel #{{ parcelInd + 1 }}

Parcel Type
-
+
{{ parcel.ownershipType?.label }}
Legal Description
-
+
{{ parcel.legalDescription }} Parcel #{{ parcelInd + 1 }} >
Approx. Map Area
-
+
{{ parcel.mapAreaHectares }} ha Parcel #{{ parcelInd + 1 }}
PID {{ parcel.ownershipType.code === PARCEL_OWNERSHIP_TYPES.CROWN ? '(optional)' : '' }}
-
- {{ parcel.pid | mask : '000-000-000' }} +
+ {{ parcel.pid | mask: '000-000-000' }} Parcel #{{ parcelInd + 1 }}
PIN (optional)
-
+
{{ parcel.pin }} Parcel #{{ parcelInd + 1 }}
Purchase Date
-
+
{{ parcel.purchasedDate | date }} Parcel #{{ parcelInd + 1 }}
Farm Classification
-
+
{{ parcel.isFarm ? 'Yes' : 'No' }}
Civic Address
-
+
{{ parcel.civicAddress }}
@@ -95,7 +95,7 @@

Parcel #{{ parcelInd + 1 }}

{{ parcel.certificateOfTitle.fileName }} @@ -126,7 +126,7 @@
Government Parcel Contact
Phone
- {{ parcel.owners[0].phoneNumber ?? '' | mask : '(000) 000-0000' }} + {{ parcel.owners[0].phoneNumber ?? '' | mask: '(000) 000-0000' }}
Email
@@ -150,21 +150,21 @@
Government Parcel Contact
Phone
Email
Corporate Summary
- -
+ +
{{ owner.displayName }}
-
+
{{ owner.organizationName }}
-
- {{ owner.phoneNumber ?? '' | mask : '(000) 000-0000' }} +
+ {{ owner.phoneNumber ?? '' | mask: '(000) 000-0000' }}
-
+
{{ owner.email }}
-
+
{{ owner.corporateSummary.fileName }} @@ -184,7 +184,7 @@
Government Parcel Contact
I confirm that the owner information provided above matches the current Certificate of Title
-
+
Yes
diff --git a/portal-frontend/src/app/features/applications/edit-submission/land-use/land-use.component.html b/portal-frontend/src/app/features/applications/edit-submission/land-use/land-use.component.html index 81693b4eda..1c2ecf7ac2 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/land-use/land-use.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/land-use/land-use.component.html @@ -117,7 +117,12 @@
Choose and describe neighbouring land uses.
- + {{ enum.value }} @@ -140,6 +145,7 @@
Choose and describe neighbouring land uses.
maxlength="500" [formControl]="northLandUseTypeDescription" aria-label="North land use type description" + data-testid="north-activity-textbox" />
Choose and describe neighbouring land uses.
- + {{ enum.value }} @@ -185,6 +196,7 @@
Choose and describe neighbouring land uses.
matInput maxlength="500" aria-label="East land use type description" + data-testid="east-activity-textbox" />
Choose and describe neighbouring land uses.
- + {{ enum.value }} @@ -230,6 +247,7 @@
Choose and describe neighbouring land uses.
matInput [formControl]="southLandUseTypeDescription" aria-label="South land use type description" + data-testid="south-activity-textbox" />
Choose and describe neighbouring land uses.
- + {{ enum.value }} @@ -275,6 +298,7 @@
Choose and describe neighbouring land uses.
matInput [formControl]="westLandUseTypeDescription" aria-label="West land use type description" + data-testid="west-activity-textbox" />
Proposal [showErrors]="showErrors" [isRequired]="true" [showVirusError]="showServingNoticeVirus" + data-testid="proof-of-serving-notice-filechooser" >
@@ -193,6 +194,7 @@

Proposal

[showErrors]="showErrors" [isRequired]="true" [showVirusError]="showProposalMapVirus" + data-testid="proposal-map-filechooser" >
diff --git a/portal-frontend/src/app/shared/custom-stepper/custom-stepper.component.html b/portal-frontend/src/app/shared/custom-stepper/custom-stepper.component.html index 1f0bc75923..623df9798e 100644 --- a/portal-frontend/src/app/shared/custom-stepper/custom-stepper.component.html +++ b/portal-frontend/src/app/shared/custom-stepper/custom-stepper.component.html @@ -4,7 +4,7 @@ -
+
From 47dfe86ab47af7249e92c291bb572240a0361eac Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Thu, 2 May 2024 17:14:59 -0700 Subject: [PATCH 023/103] Add ALCS verification - Split to separate sequential test to avoid redirect issue --- .../application-details.component.html | 46 +++---- .../parcel/parcel.component.html | 44 +++--- .../tur-details/tur-details.component.html | 16 +-- e2e/tests/pages/alcs/alcs-login-page.ts | 31 +++++ .../applicant-info-page.ts | 27 ++++ .../applicant-info-page/land-use-section.ts | 27 ++++ .../optional-documents-section.ts | 28 ++++ .../other-owned-parcels-section.ts | 21 +++ .../applicant-info-page/parcels-section.ts | 125 ++++++++++++++++++ .../primary-contact-section.ts | 45 +++++++ .../tur-proposal-section.ts | 39 ++++++ e2e/tests/pages/alcs/details-navigation.ts | 15 +++ e2e/tests/pages/alcs/home-page.ts | 36 +++++ e2e/tests/tur.spec.ts | 117 +++++++++------- 14 files changed, 523 insertions(+), 94 deletions(-) create mode 100644 e2e/tests/pages/alcs/alcs-login-page.ts create mode 100644 e2e/tests/pages/alcs/applicant-info-page/applicant-info-page.ts create mode 100644 e2e/tests/pages/alcs/applicant-info-page/land-use-section.ts create mode 100644 e2e/tests/pages/alcs/applicant-info-page/optional-documents-section.ts create mode 100644 e2e/tests/pages/alcs/applicant-info-page/other-owned-parcels-section.ts create mode 100644 e2e/tests/pages/alcs/applicant-info-page/parcels-section.ts create mode 100644 e2e/tests/pages/alcs/applicant-info-page/primary-contact-section.ts create mode 100644 e2e/tests/pages/alcs/applicant-info-page/tur-proposal-section.ts create mode 100644 e2e/tests/pages/alcs/details-navigation.ts create mode 100644 e2e/tests/pages/alcs/home-page.ts diff --git a/alcs-frontend/src/app/features/application/applicant-info/application-details/application-details.component.html b/alcs-frontend/src/app/features/application/applicant-info/application-details/application-details.component.html index aa03160e66..a5e9cb754d 100644 --- a/alcs-frontend/src/app/features/application/applicant-info/application-details/application-details.component.html +++ b/alcs-frontend/src/app/features/application/applicant-info/application-details/application-details.component.html @@ -14,7 +14,7 @@

Other Owned Parcels

Do any of the land owners added previously own or lease other parcels that might inform this application process?
-
+
{{ submission.hasOtherParcelsInCommunity ? 'Yes' : 'No' }} @@ -24,7 +24,7 @@

Other Owned Parcels

Describe the other parcels including their location, who owns or leases them, and their use.
-
+
{{ submission.otherParcelsDescription }}
@@ -42,17 +42,17 @@

Other Owned Parcels

Primary Contact Information

Type
-
+
{{ submission.primaryContact?.type?.label }}
First Name
-
+
{{ submission.primaryContact?.firstName }}
Last Name
-
+
{{ submission.primaryContact?.lastName }}
@@ -63,17 +63,17 @@

Primary Contact Information

Ministry/Department Responsible Department
-
+
{{ submission.primaryContact?.organizationName }}
Phone
-
+
{{ submission.primaryContact?.phoneNumber }}
Email
-
+
{{ submission.primaryContact?.email }}
@@ -81,7 +81,7 @@

Primary Contact Information

Authorization Letter(s)
@@ -99,15 +99,15 @@

Land Use

Land Use of Parcel(s) under Application

Describe all agriculture that currently takes place on the parcel(s).
-
+
{{ submission.parcelsAgricultureDescription }}
Describe all agricultural improvements made to the parcel(s).
-
+
{{ submission.parcelsAgricultureImprovementDescription }}
Describe all other uses that currently take place on the parcel(s).
-
+
{{ submission.parcelsNonAgricultureUseDescription }}
@@ -118,31 +118,31 @@

Land Use of Adjacent Parcels

Main Land Use Type
Specific Activity
North
-
+
{{ submission.northLandUseType }}
-
+
{{ submission.northLandUseTypeDescription }}
East
-
+
{{ submission.eastLandUseType }}
-
+
{{ submission.eastLandUseTypeDescription }}
South
-
+
{{ submission.southLandUseType }}
-
+
{{ submission.southLandUseTypeDescription }}
West
-
+
{{ submission.westLandUseType }}
-
+
{{ submission.westLandUseTypeDescription }}
@@ -225,13 +225,13 @@

Optional Documents

Description
-
+ -
+
{{ file.type?.label }}
-
+
{{ file.description }}
diff --git a/alcs-frontend/src/app/features/application/applicant-info/application-details/parcel/parcel.component.html b/alcs-frontend/src/app/features/application/applicant-info/application-details/parcel/parcel.component.html index 21935309a3..42018c50b0 100644 --- a/alcs-frontend/src/app/features/application/applicant-info/application-details/parcel/parcel.component.html +++ b/alcs-frontend/src/app/features/application/applicant-info/application-details/parcel/parcel.component.html @@ -12,53 +12,57 @@

Parcel #{{ parcelInd + 1 }}: Parcel and Owner Information

Ownership Type
-
+
{{ parcel.ownershipType?.label }}
Legal Description
-
+
{{ parcel.legalDescription }}
Area (Hectares)
-
+
{{ parcel.mapAreaHectares }}
PID {{ parcel.ownershipType?.code === PARCEL_OWNERSHIP_TYPES.CROWN ? '(optional)' : '' }}
-
+
{{ parcel.pid | mask: '000-000-000' }}
PIN (optional)
-
+
{{ parcel.pin }}
Purchase Date
-
+
{{ parcel.purchasedDate | date }}
Farm Classification
-
+
{{ parcel.isFarm ? 'Yes' : 'No' }}
Civic Address
-
+
{{ parcel.civicAddress }}
Certificate Of Title
-
+
@@ -108,17 +112,23 @@
Government Parcel Contact
Email
Corporate Summary
-
{{ owner.displayName }}
-
+
+ {{ owner.displayName }} +
+
{{ owner.organizationName }}
-
{{ owner.phoneNumber }}
-
{{ owner.email }}
-
- {{ - owner.corporateSummary.fileName - }} +
+ {{ owner.phoneNumber }} +
+
+ {{ owner.email }} +
+ diff --git a/alcs-frontend/src/app/features/application/applicant-info/application-details/tur-details/tur-details.component.html b/alcs-frontend/src/app/features/application/applicant-info/application-details/tur-details/tur-details.component.html index c463469f5d..1652cc058d 100644 --- a/alcs-frontend/src/app/features/application/applicant-info/application-details/tur-details/tur-details.component.html +++ b/alcs-frontend/src/app/features/application/applicant-info/application-details/tur-details/tur-details.component.html @@ -1,37 +1,37 @@
What is the purpose of the proposal?
-
+
{{ _application.purpose }}
Specify any agricultural activities such as livestock operations, greenhouses or horticultural activities in proximity to the proposal.
-
+
{{ _application.turAgriculturalActivities }}
What steps will you take to reduce potential negative impacts on surrounding agricultural lands?
-
+
{{ _application.turReduceNegativeImpacts }}
Could this proposal be accommodated on lands outside of the ALR?
-
+
{{ _application.turOutsideLands }}
Total area of corridor
-
- {{ _application.turTotalCorridorArea }}ha +
+ {{ _application.turTotalCorridorArea }} ha
Proof of Serving Notice
-
+
Proposal Map / Site Plan
-
+
{{ file.fileName }} diff --git a/e2e/tests/pages/alcs/alcs-login-page.ts b/e2e/tests/pages/alcs/alcs-login-page.ts new file mode 100644 index 0000000000..440e72eafd --- /dev/null +++ b/e2e/tests/pages/alcs/alcs-login-page.ts @@ -0,0 +1,31 @@ +import { type Locator, type Page } from '@playwright/test'; + +export class ALCSLoginPage { + readonly page: Page; + readonly baseUrl: string; + readonly idirLink: Locator; + readonly userIdTextbox: Locator; + readonly passwordTextbox: Locator; + readonly continueButton: Locator; + + constructor(page: Page, baseUrl: string) { + this.page = page; + this.baseUrl = baseUrl; + this.idirLink = page.getByRole('link', { name: 'IDIR' }); + // There is an error with the username label on BCeID page + this.userIdTextbox = page.getByRole('textbox').nth(0); + this.passwordTextbox = page.getByLabel('Password'); + this.continueButton = page.getByRole('button', { name: 'Continue' }); + } + + async goto() { + await this.page.goto(this.baseUrl); + } + + async login(username: string, password: string) { + await this.idirLink.click(); + await this.userIdTextbox.fill(username); + await this.passwordTextbox.fill(password); + await this.continueButton.click(); + } +} diff --git a/e2e/tests/pages/alcs/applicant-info-page/applicant-info-page.ts b/e2e/tests/pages/alcs/applicant-info-page/applicant-info-page.ts new file mode 100644 index 0000000000..f63373670c --- /dev/null +++ b/e2e/tests/pages/alcs/applicant-info-page/applicant-info-page.ts @@ -0,0 +1,27 @@ +import { type Locator, type Page } from '@playwright/test'; +import { ParcelsSection } from './parcels-section'; +import { PrimaryContactSection } from './primary-contact-section'; +import { OtherOwnedParcelsSection } from './other-owned-parcels-section'; +import { LandUseSection } from './land-use-section'; +import { TURProposalSection } from './tur-proposal-section'; +import { OptionalDocumentsSection } from './optional-documents-section'; + +export class ALCSApplicantInfoPage { + readonly page: Page; + readonly parcelsSection: ParcelsSection; + readonly otherOwnedParcelsSection: OtherOwnedParcelsSection; + readonly primaryContactSection: PrimaryContactSection; + readonly landUseSection: LandUseSection; + readonly turProposalSection: TURProposalSection; + readonly optionalDocumentsSection: OptionalDocumentsSection; + + constructor(page: Page) { + this.page = page; + this.parcelsSection = new ParcelsSection(page); + this.otherOwnedParcelsSection = new OtherOwnedParcelsSection(page); + this.primaryContactSection = new PrimaryContactSection(page); + this.landUseSection = new LandUseSection(page); + this.turProposalSection = new TURProposalSection(page); + this.optionalDocumentsSection = new OptionalDocumentsSection(page); + } +} diff --git a/e2e/tests/pages/alcs/applicant-info-page/land-use-section.ts b/e2e/tests/pages/alcs/applicant-info-page/land-use-section.ts new file mode 100644 index 0000000000..c515e96c5c --- /dev/null +++ b/e2e/tests/pages/alcs/applicant-info-page/land-use-section.ts @@ -0,0 +1,27 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { type LandUseDetails } from '../../portal/land-use-page'; + +export class LandUseSection { + readonly page: Page; + readonly currentAgricultureText: Locator; + readonly improvementsText: Locator; + readonly otherUsesText: Locator; + + constructor(page: Page) { + this.page = page; + this.currentAgricultureText = page.getByTestId('parcels-agriculture-description'); + this.improvementsText = page.getByTestId('parcels-agriculture-improvement-description'); + this.otherUsesText = page.getByTestId('parcels-non-agriculture-description'); + } + + async expectLandUse(landUse: LandUseDetails) { + await expect(this.currentAgricultureText).toHaveText(landUse.currentAgriculture); + await expect(this.improvementsText).toHaveText(landUse.improvements); + await expect(this.otherUsesText).toHaveText(landUse.otherUses); + + for (const [direction, neighbouring] of landUse.neighbouringLandUse) { + await expect(this.page.getByTestId(`${direction}-land-use-type`)).toHaveText(neighbouring.type); + await expect(this.page.getByTestId(`${direction}-land-use-description`)).toHaveText(neighbouring.activity); + } + } +} diff --git a/e2e/tests/pages/alcs/applicant-info-page/optional-documents-section.ts b/e2e/tests/pages/alcs/applicant-info-page/optional-documents-section.ts new file mode 100644 index 0000000000..65c6e673c0 --- /dev/null +++ b/e2e/tests/pages/alcs/applicant-info-page/optional-documents-section.ts @@ -0,0 +1,28 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { OptionalAttachment } from '../../portal/optional-attachments-page'; + +export class OptionalDocumentsSection { + readonly page: Page; + readonly fileNameTexts: Locator; + readonly typeTexts: Locator; + readonly descriptionTexts: Locator; + + constructor(page: Page) { + this.page = page; + this.fileNameTexts = page.getByTestId(`optional-document-file-name`); + this.typeTexts = page.getByTestId(`optional-document-type`); + this.descriptionTexts = page.getByTestId(`optional-document-description`); + } + + async expectAttachments(attachments: OptionalAttachment[]) { + for (const attachment of attachments) { + await expect(this.fileNameTexts.filter({ hasText: this.fileName(attachment.path) })).toBeVisible(); + await expect(this.typeTexts.filter({ hasText: attachment.type })).toBeVisible(); + await expect(this.descriptionTexts.filter({ hasText: attachment.description })).toBeVisible(); + } + } + + fileName(path: string): string { + return path.substring(path.lastIndexOf('/') + 1); + } +} diff --git a/e2e/tests/pages/alcs/applicant-info-page/other-owned-parcels-section.ts b/e2e/tests/pages/alcs/applicant-info-page/other-owned-parcels-section.ts new file mode 100644 index 0000000000..3773110006 --- /dev/null +++ b/e2e/tests/pages/alcs/applicant-info-page/other-owned-parcels-section.ts @@ -0,0 +1,21 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +export class OtherOwnedParcelsSection { + readonly page: Page; + readonly hasOtherParcelsText: Locator; + readonly descriptionText: Locator; + + constructor(page: Page) { + this.page = page; + this.hasOtherParcelsText = page.getByTestId('has-other-parcels'); + this.descriptionText = page.getByTestId('other-parcels-description'); + } + + async expectHasOtherParcels(hasOtherParcels: boolean) { + await expect(this.hasOtherParcelsText).toHaveText(hasOtherParcels ? 'Yes' : 'No'); + } + + async expectDescription(description: string) { + await expect(this.descriptionText).toHaveText(description); + } +} diff --git a/e2e/tests/pages/alcs/applicant-info-page/parcels-section.ts b/e2e/tests/pages/alcs/applicant-info-page/parcels-section.ts new file mode 100644 index 0000000000..29b3d7520e --- /dev/null +++ b/e2e/tests/pages/alcs/applicant-info-page/parcels-section.ts @@ -0,0 +1,125 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { type ParcelDetails, type ParcelOwnerDetails } from '../../portal/parcels-page'; + +export class ParcelsSection { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async expectParcels(parcels: ParcelDetails[]) { + for (const [parcel, i] of parcels.map((parcel, i): [ParcelDetails, number] => [parcel, i])) { + const parcelNumber = i + 1; + + await this.expectParcelDetails(parcelNumber, parcel); + await this.expectParcelOwners(parcelNumber, parcel.owners); + } + } + + async expectParcelDetails(parcelNumber: number, parcel: ParcelDetails) { + await expect(this.typeText(parcelNumber)).toHaveText(parcel.type); + await expect(this.legalDescriptionText(parcelNumber)).toHaveText(parcel.legalDescription); + await expect(this.mapAreaText(parcelNumber)).toHaveText(parcel.mapArea); + if (parcel.pid !== undefined) { + await expect(this.pidText(parcelNumber)).toHaveText(parcel.pid); + } + if (parcel.pin !== undefined) { + await expect(this.pinText(parcelNumber)).toHaveText(parcel.pin); + } + if (parcel.month !== undefined && parcel.day !== undefined && parcel.year !== undefined) { + await expect(this.purchaseDateText(parcelNumber)).toHaveText(`${parcel.month} ${parcel.day}, ${parcel.year}`); + } + await expect(this.isFarmText(parcelNumber)).toHaveText(parcel.isFarm ? 'Yes' : 'No'); + await expect(this.civicAddressText(parcelNumber)).toHaveText(parcel.civicAddress); + await expect(this.certificateOfTitleText(parcelNumber)).toHaveText(this.fileName(parcel.certificateOfTitlePath)); + } + + async expectParcelOwners(parcelNumber: number, owners: ParcelOwnerDetails[]) { + const nameTexts = this.ownerNameTexts(parcelNumber); + const organizationTexts = this.ownerOrganizationTexts(parcelNumber); + const phoneNumberTexts = this.ownerPhoneNumberTexts(parcelNumber); + const emailTexts = this.ownerEmailTexts(parcelNumber); + const corporateSummaryTexts = this.ownerCorporateSummaryTexts(parcelNumber); + + for (const owner of owners) { + await expect(nameTexts.filter({ hasText: `${owner.firstName} ${owner.lastName}` })).toBeVisible(); + if (owner.organization !== undefined) { + await expect(organizationTexts.filter({ hasText: owner.organization })).toBeVisible(); + } + await expect(phoneNumberTexts.filter({ hasText: owner.phoneNumber.replace(/\D/g, '') })).toBeVisible(); + await expect(emailTexts.filter({ hasText: owner.email })).toBeVisible(); + if (owner.corporateSummaryPath !== undefined) { + await expect( + corporateSummaryTexts.filter({ hasText: this.fileName(owner.corporateSummaryPath) }), + ).toBeVisible(); + } + } + } + + // Locator Getters + // --------------- + + typeText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-type`); + } + + legalDescriptionText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-legal-description`); + } + + mapAreaText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-map-area`); + } + + pidText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-pid`); + } + + pinText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-pin`); + } + + purchaseDateText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-purchase-date`); + } + + isFarmText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-is-farm`); + } + + civicAddressText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-civic-address`); + } + + certificateOfTitleText(parcelNumber: number): Locator { + return this.page.getByTestId(`parcel-${parcelNumber}-certificate-of-title`); + } + + ownerNameTexts(parcelNumber: number) { + return this.page.getByTestId(`parcel-${parcelNumber}-owner-name`); + } + + ownerOrganizationTexts(parcelNumber: number) { + return this.page.getByTestId(`parcel-${parcelNumber}-owner-organization`); + } + + ownerPhoneNumberTexts(parcelNumber: number) { + return this.page.getByTestId(`parcel-${parcelNumber}-owner-phone-number`); + } + + ownerEmailTexts(parcelNumber: number) { + return this.page.getByTestId(`parcel-${parcelNumber}-owner-email`); + } + + ownerCorporateSummaryTexts(parcelNumber: number) { + return this.page.getByTestId(`parcel-${parcelNumber}-owner-corporate-summary`); + } + + // Utils + // ----- + + fileName(path: string): string { + return path.substring(path.lastIndexOf('/') + 1); + } +} diff --git a/e2e/tests/pages/alcs/applicant-info-page/primary-contact-section.ts b/e2e/tests/pages/alcs/applicant-info-page/primary-contact-section.ts new file mode 100644 index 0000000000..726d2fe648 --- /dev/null +++ b/e2e/tests/pages/alcs/applicant-info-page/primary-contact-section.ts @@ -0,0 +1,45 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { PrimaryContactType, type ThirdPartyContactDetails } from '../../portal/primary-contact-page'; + +export class PrimaryContactSection { + readonly page: Page; + readonly typeText: Locator; + readonly firstNameText: Locator; + readonly lastNameText: Locator; + readonly organizationText: Locator; + readonly phoneNumberText: Locator; + readonly emailText: Locator; + readonly authorizationLetterTexts: Locator; + + constructor(page: Page) { + this.page = page; + this.typeText = page.getByTestId('primary-contact-type'); + this.firstNameText = page.getByTestId('primary-contact-first-name'); + this.lastNameText = page.getByTestId('primary-contact-last-name'); + this.organizationText = page.getByTestId('primary-contact-organization'); + this.phoneNumberText = page.getByTestId('primary-contact-phone-number'); + this.emailText = page.getByTestId('primary-contact-email'); + this.authorizationLetterTexts = page.getByTestId('authorization-letter'); + } + + async expectThirdPartyContact(contact: ThirdPartyContactDetails) { + await expect(this.typeText).toHaveText(PrimaryContactType.ThirdParty); + await expect(this.firstNameText).toHaveText(contact.firstName); + await expect(this.lastNameText).toHaveText(contact.lastName); + if (contact.organization !== undefined) { + await expect(this.organizationText).toHaveText(contact.organization); + } + await expect(this.phoneNumberText).toHaveText(contact.phoneNumber.replace(/\D/g, '')); + await expect(this.emailText).toHaveText(contact.email); + } + + async expectAuthorizationLetters(authorizationLetterPaths: string[]) { + for (const path of authorizationLetterPaths) { + await expect(this.authorizationLetterTexts.filter({ hasText: this.fileName(path) })).toBeVisible(); + } + } + + fileName(path: string): string { + return path.substring(path.lastIndexOf('/') + 1); + } +} diff --git a/e2e/tests/pages/alcs/applicant-info-page/tur-proposal-section.ts b/e2e/tests/pages/alcs/applicant-info-page/tur-proposal-section.ts new file mode 100644 index 0000000000..c8a52be3ed --- /dev/null +++ b/e2e/tests/pages/alcs/applicant-info-page/tur-proposal-section.ts @@ -0,0 +1,39 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { TURProposal } from '../../portal/tur-proposal-page'; + +export class TURProposalSection { + readonly page: Page; + readonly purposeText: Locator; + readonly activitiesText: Locator; + readonly stepsToReduceImpactText: Locator; + readonly alternativeLandText: Locator; + readonly totalAreaText: Locator; + readonly allOwnersNotifiedText: Locator; + readonly proofOfServingNoticeText: Locator; + readonly proposalMapText: Locator; + + constructor(page: Page) { + this.page = page; + this.purposeText = page.getByTestId('tur-purpose'); + this.activitiesText = page.getByTestId('tur-agricultural-activities'); + this.stepsToReduceImpactText = page.getByTestId('tur-reduce-negative-impacts'); + this.alternativeLandText = page.getByTestId('tur-outside-lands'); + this.totalAreaText = page.getByTestId('tur-total-corridor-area'); + this.proofOfServingNoticeText = page.getByTestId('tur-proof-of-serving-notice'); + this.proposalMapText = page.getByTestId('tur-proposal-map'); + } + + async expectProposal(proposal: TURProposal) { + await expect(this.purposeText).toHaveText(proposal.purpose); + await expect(this.activitiesText).toHaveText(proposal.activities); + await expect(this.stepsToReduceImpactText).toHaveText(proposal.stepsToReduceImpact); + await expect(this.alternativeLandText).toHaveText(proposal.alternativeLand); + await expect(this.totalAreaText).toHaveText(`${proposal.totalArea} ha`); + await expect(this.proofOfServingNoticeText).toHaveText(this.fileName(proposal.proofOfServingNoticePath)); + await expect(this.proposalMapText).toHaveText(this.fileName(proposal.proposalMapPath)); + } + + fileName(path: string): string { + return path.substring(path.lastIndexOf('/') + 1); + } +} diff --git a/e2e/tests/pages/alcs/details-navigation.ts b/e2e/tests/pages/alcs/details-navigation.ts new file mode 100644 index 0000000000..8132e28931 --- /dev/null +++ b/e2e/tests/pages/alcs/details-navigation.ts @@ -0,0 +1,15 @@ +import { type Locator, type Page } from '@playwright/test'; + +export class ALCSDetailsNavigation { + readonly page: Page; + readonly applicantInfoLink: Locator; + + constructor(page: Page) { + this.page = page; + this.applicantInfoLink = page.getByText('Applicant Info'); + } + + async gotoApplicantInfoPage() { + await this.applicantInfoLink.click(); + } +} diff --git a/e2e/tests/pages/alcs/home-page.ts b/e2e/tests/pages/alcs/home-page.ts new file mode 100644 index 0000000000..97c261059f --- /dev/null +++ b/e2e/tests/pages/alcs/home-page.ts @@ -0,0 +1,36 @@ +import { type Locator, type Page } from '@playwright/test'; + +export class ALCSHomePage { + readonly page: Page; + readonly baseUrl: string; + readonly idirLink: Locator; + readonly userIdTextbox: Locator; + readonly passwordTextbox: Locator; + readonly continueButton: Locator; + + constructor(page: Page) { + this.page = page; + this.idirLink = page.getByRole('link', { name: 'IDIR' }); + // There is an error with the username label on BCeID page + this.userIdTextbox = page.getByRole('textbox').nth(0); + this.passwordTextbox = page.getByLabel('Password'); + this.continueButton = page.getByRole('button', { name: 'Continue' }); + } + + async goto() { + await this.page.goto(this.baseUrl); + } + + async login(username: string, password: string) { + await this.idirLink.click(); + await this.userIdTextbox.fill(username); + await this.passwordTextbox.fill(password); + await this.continueButton.click(); + } + + async search(fileId: string) { + await this.page.getByRole('button').filter({ hasText: 'search' }).click(); + await this.page.getByPlaceholder('Search by File ID').fill(fileId); + await this.page.keyboard.press('Enter'); + } +} diff --git a/e2e/tests/tur.spec.ts b/e2e/tests/tur.spec.ts index b2f9cbfcec..edf579ec6b 100644 --- a/e2e/tests/tur.spec.ts +++ b/e2e/tests/tur.spec.ts @@ -11,8 +11,12 @@ import { TURProposalPage } from './pages/portal/tur-proposal-page'; import { OptionalAttachmentType, OptionalAttachmentsPage } from './pages/portal/optional-attachments-page'; import { ReviewAndSubmitPage } from './pages/portal/review-and-submit-page/review-and-submit-page'; import { SubmissionSuccessPage } from './pages/portal/submission-success-page'; +import { ALCSLoginPage } from './pages/alcs/alcs-login-page'; +import { ALCSHomePage } from './pages/alcs/home-page'; +import { ALCSDetailsNavigation } from './pages/alcs/details-navigation'; +import { ALCSApplicantInfoPage } from './pages/alcs/applicant-info-page/applicant-info-page'; -test('TUR', async ({ browser }) => { +test.describe.serial('Portal TUR submission and ALCS applicant info flow', () => { const parcels = [ { type: ParcelType.FeeSimple, @@ -117,68 +121,89 @@ test('TUR', async ({ browser }) => { let submittedFileId: string; - const context = await browser.newContext({ baseURL: process.env.PORTAL_BASE_URL }); - const page = await context.newPage(); + test('should have working UI, data should populate review, and submission should succeed', async ({ page }) => { + const portalLoginPage = new PortalLoginPage(page, process.env.PORTAL_BASE_URL); + await portalLoginPage.goto(); + await portalLoginPage.logIn(process.env.BCEID_BASIC_USERNAME, process.env.BCEID_BASIC_PASSWORD); - const portalLoginPage = new PortalLoginPage(page); - await portalLoginPage.goto(); - await portalLoginPage.logIn(process.env.BCEID_BASIC_USERNAME, process.env.BCEID_BASIC_PASSWORD); + const inboxPage = new InboxPage(page); + await inboxPage.createApplication(ApplicationType.TUR); - const inbox = new InboxPage(page); - await inbox.createApplication(ApplicationType.TUR); + const portalStepsNavigation = new PortalStepsNavigation(page); - const portalStepsNavigation = new PortalStepsNavigation(page); + const parcelsPage = new ParcelsPage(page); + await parcelsPage.fill(parcels); - const parcelsPage = new ParcelsPage(page); - await parcelsPage.fill(parcels); + await portalStepsNavigation.gotoOtherParcelsPage(); - await portalStepsNavigation.gotoOtherParcelsPage(); + const otherParcelsPage = new OtherParcelsPage(page); + await otherParcelsPage.setHasOtherParcels(hasOtherParcels); + await otherParcelsPage.fillDescription(otherParcelsDescription); - const otherParcelsPage = new OtherParcelsPage(page); - await otherParcelsPage.setHasOtherParcels(hasOtherParcels); - await otherParcelsPage.fillDescription(otherParcelsDescription); + await portalStepsNavigation.gotoPrimaryContactPage(); - await portalStepsNavigation.gotoPrimaryContactPage(); + const primaryContactPage = new PrimaryContactPage(page); + await primaryContactPage.setPrimaryContactType(primaryContactType); + await primaryContactPage.fillThirdPartyContact(thirdPartPrimaryContact); + await primaryContactPage.uploadAuthorizationLetters(authorizationLetterPaths); - const primaryContactPage = new PrimaryContactPage(page); - await primaryContactPage.setPrimaryContactType(primaryContactType); - await primaryContactPage.fillThirdPartyContact(thirdPartPrimaryContact); - await primaryContactPage.uploadAuthorizationLetters(authorizationLetterPaths); + await portalStepsNavigation.gotoGovernmentPage(); - await portalStepsNavigation.gotoGovernmentPage(); + const governmentPage = new GovernmentPage(page); + await governmentPage.fill(government); - const governmentPage = new GovernmentPage(page); - await governmentPage.fill(government); + await portalStepsNavigation.gotoLandUsePage(); - await portalStepsNavigation.gotoLandUsePage(); + const landUsePage = new LandUsePage(page); + await landUsePage.fill(landUse); - const landUsePage = new LandUsePage(page); - await landUsePage.fill(landUse); + await portalStepsNavigation.gotoProposalPage(); - await portalStepsNavigation.gotoProposalPage(); + const turProposalPage = new TURProposalPage(page); + await turProposalPage.fill(turProposal); - const turProposalPage = new TURProposalPage(page); - await turProposalPage.fill(turProposal); + await portalStepsNavigation.gotoOptionalAttachmentsPage(); - await portalStepsNavigation.gotoOptionalAttachmentsPage(); + const optionalAttachmentsPage = new OptionalAttachmentsPage(page); + await optionalAttachmentsPage.addAttachments(optionalAttachments); - const optionalAttachmentsPage = new OptionalAttachmentsPage(page); - await optionalAttachmentsPage.addAttachments(optionalAttachments); + await portalStepsNavigation.gotoReviewAndSubmitPage(); - await portalStepsNavigation.gotoReviewAndSubmitPage(); + const reviewAndSubmitPage = new ReviewAndSubmitPage(page); + await reviewAndSubmitPage.parcelsSection.expectParcels(parcels); + await reviewAndSubmitPage.otherOwnedParcelsSection.expectHasOtherParcels(hasOtherParcels); + await reviewAndSubmitPage.otherOwnedParcelsSection.expectDescription(otherParcelsDescription); + await reviewAndSubmitPage.primaryContactSection.expectThirdPartyContact(thirdPartPrimaryContact); + await reviewAndSubmitPage.primaryContactSection.expectAuthorizationLetters(authorizationLetterPaths); + await reviewAndSubmitPage.governmentSection.expectGovernment(government); + await reviewAndSubmitPage.landUseSection.expectLandUse(landUse); + await reviewAndSubmitPage.turProposalSection.expectProposal(turProposal); + await reviewAndSubmitPage.optionalDocumentsSection.expectAttachments(optionalAttachments); + await reviewAndSubmitPage.submit(); - const reviewAndSubmitPage = new ReviewAndSubmitPage(page); - await reviewAndSubmitPage.parcelsSection.expectParcels(parcels); - await reviewAndSubmitPage.otherOwnedParcelsSection.expectHasOtherParcels(hasOtherParcels); - await reviewAndSubmitPage.otherOwnedParcelsSection.expectDescription(otherParcelsDescription); - await reviewAndSubmitPage.primaryContactSection.expectThirdPartyContact(thirdPartPrimaryContact); - await reviewAndSubmitPage.primaryContactSection.expectAuthorizationLetters(authorizationLetterPaths); - await reviewAndSubmitPage.governmentSection.expectGovernment(government); - await reviewAndSubmitPage.landUseSection.expectLandUse(landUse); - await reviewAndSubmitPage.turProposalSection.expectProposal(turProposal); - await reviewAndSubmitPage.optionalDocumentsSection.expectAttachments(optionalAttachments); - await reviewAndSubmitPage.submit(); + const submissionSuccessPage = new SubmissionSuccessPage(page); + submittedFileId = await submissionSuccessPage.fileId(); + }); - const submissionSuccessPage = new SubmissionSuccessPage(page); - submittedFileId = await submissionSuccessPage.fileId(); + test('submission data should appear in ALCS applicant info', async ({ page }) => { + const alcsLoginPage = new ALCSLoginPage(page, process.env.ALCS_BASE_URL); + await alcsLoginPage.goto(); + await alcsLoginPage.login(process.env.IDIR_USERNAME, process.env.IDIR_PASSWORD); + + const alcsHomePage = new ALCSHomePage(page); + await alcsHomePage.search(submittedFileId); + + const alcsDetailsNavigation = new ALCSDetailsNavigation(page); + await alcsDetailsNavigation.gotoApplicantInfoPage(); + + const alcsApplicantInfoPage = new ALCSApplicantInfoPage(page); + await alcsApplicantInfoPage.parcelsSection.expectParcels(parcels); + await alcsApplicantInfoPage.otherOwnedParcelsSection.expectHasOtherParcels(hasOtherParcels); + await alcsApplicantInfoPage.otherOwnedParcelsSection.expectDescription(otherParcelsDescription); + await alcsApplicantInfoPage.primaryContactSection.expectThirdPartyContact(thirdPartPrimaryContact); + await alcsApplicantInfoPage.primaryContactSection.expectAuthorizationLetters(authorizationLetterPaths); + await alcsApplicantInfoPage.landUseSection.expectLandUse(landUse); + await alcsApplicantInfoPage.turProposalSection.expectProposal(turProposal); + await alcsApplicantInfoPage.optionalDocumentsSection.expectAttachments(optionalAttachments); + }); }); From 860dcb6746de612d5c08a39bf751d39d25fe59e8 Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Thu, 2 May 2024 17:15:50 -0700 Subject: [PATCH 024/103] Update .env template to include other users/URL's --- e2e/template.env | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/e2e/template.env b/e2e/template.env index 9feb6fb1f4..eabff51721 100644 --- a/e2e/template.env +++ b/e2e/template.env @@ -1,3 +1,11 @@ PORTAL_BASE_URL= +ALCS_BASE_URL= + BCEID_BASIC_USERNAME= BCEID_BASIC_PASSWORD= + +BCEID_LG_USERNAME= +BCEID_LG_PASSWORD= + +IDIR_USERNAME= +IDIR_PASSWORD= From 4bc5e6017b7f433b30946b691548be9a9f022c7f Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Thu, 2 May 2024 17:20:51 -0700 Subject: [PATCH 025/103] Move pages outside of tests directory --- e2e/{tests => }/pages/alcs/alcs-login-page.ts | 0 .../applicant-info-page.ts | 0 .../applicant-info-page/land-use-section.ts | 0 .../optional-documents-section.ts | 0 .../other-owned-parcels-section.ts | 0 .../applicant-info-page/parcels-section.ts | 0 .../primary-contact-section.ts | 0 .../tur-proposal-section.ts | 0 .../pages/alcs/details-navigation.ts | 0 e2e/{tests => }/pages/alcs/home-page.ts | 0 e2e/{tests => }/pages/inbox-page.ts | 0 e2e/{tests => }/pages/portal-login-page.ts | 0 .../pages/portal/government-page.ts | 0 e2e/{tests => }/pages/portal/inbox-page.ts | 0 e2e/{tests => }/pages/portal/land-use-page.ts | 0 .../pages/portal/optional-attachments-page.ts | 0 .../pages/portal/other-parcels-page.ts | 0 e2e/{tests => }/pages/portal/parcels-page.ts | 0 .../pages/portal/portal-login-page.ts | 0 .../pages/portal/portal-steps-navigation.ts | 0 .../pages/portal/primary-contact-page.ts | 0 .../government-section.ts | 0 .../land-use-section.ts | 0 .../optional-documents-section.ts | 0 .../other-owned-parcels-section.ts | 0 .../review-and-submit-page/parcels-section.ts | 0 .../primary-contact-section.ts | 0 .../review-and-submit-page.ts | 0 .../tur-proposal-section.ts | 0 .../pages/portal/submission-success-page.ts | 0 .../pages/portal/tur-proposal-page.ts | 0 e2e/tests/tur.spec.ts | 32 +++++++++---------- 32 files changed, 16 insertions(+), 16 deletions(-) rename e2e/{tests => }/pages/alcs/alcs-login-page.ts (100%) rename e2e/{tests => }/pages/alcs/applicant-info-page/applicant-info-page.ts (100%) rename e2e/{tests => }/pages/alcs/applicant-info-page/land-use-section.ts (100%) rename e2e/{tests => }/pages/alcs/applicant-info-page/optional-documents-section.ts (100%) rename e2e/{tests => }/pages/alcs/applicant-info-page/other-owned-parcels-section.ts (100%) rename e2e/{tests => }/pages/alcs/applicant-info-page/parcels-section.ts (100%) rename e2e/{tests => }/pages/alcs/applicant-info-page/primary-contact-section.ts (100%) rename e2e/{tests => }/pages/alcs/applicant-info-page/tur-proposal-section.ts (100%) rename e2e/{tests => }/pages/alcs/details-navigation.ts (100%) rename e2e/{tests => }/pages/alcs/home-page.ts (100%) rename e2e/{tests => }/pages/inbox-page.ts (100%) rename e2e/{tests => }/pages/portal-login-page.ts (100%) rename e2e/{tests => }/pages/portal/government-page.ts (100%) rename e2e/{tests => }/pages/portal/inbox-page.ts (100%) rename e2e/{tests => }/pages/portal/land-use-page.ts (100%) rename e2e/{tests => }/pages/portal/optional-attachments-page.ts (100%) rename e2e/{tests => }/pages/portal/other-parcels-page.ts (100%) rename e2e/{tests => }/pages/portal/parcels-page.ts (100%) rename e2e/{tests => }/pages/portal/portal-login-page.ts (100%) rename e2e/{tests => }/pages/portal/portal-steps-navigation.ts (100%) rename e2e/{tests => }/pages/portal/primary-contact-page.ts (100%) rename e2e/{tests => }/pages/portal/review-and-submit-page/government-section.ts (100%) rename e2e/{tests => }/pages/portal/review-and-submit-page/land-use-section.ts (100%) rename e2e/{tests => }/pages/portal/review-and-submit-page/optional-documents-section.ts (100%) rename e2e/{tests => }/pages/portal/review-and-submit-page/other-owned-parcels-section.ts (100%) rename e2e/{tests => }/pages/portal/review-and-submit-page/parcels-section.ts (100%) rename e2e/{tests => }/pages/portal/review-and-submit-page/primary-contact-section.ts (100%) rename e2e/{tests => }/pages/portal/review-and-submit-page/review-and-submit-page.ts (100%) rename e2e/{tests => }/pages/portal/review-and-submit-page/tur-proposal-section.ts (100%) rename e2e/{tests => }/pages/portal/submission-success-page.ts (100%) rename e2e/{tests => }/pages/portal/tur-proposal-page.ts (100%) diff --git a/e2e/tests/pages/alcs/alcs-login-page.ts b/e2e/pages/alcs/alcs-login-page.ts similarity index 100% rename from e2e/tests/pages/alcs/alcs-login-page.ts rename to e2e/pages/alcs/alcs-login-page.ts diff --git a/e2e/tests/pages/alcs/applicant-info-page/applicant-info-page.ts b/e2e/pages/alcs/applicant-info-page/applicant-info-page.ts similarity index 100% rename from e2e/tests/pages/alcs/applicant-info-page/applicant-info-page.ts rename to e2e/pages/alcs/applicant-info-page/applicant-info-page.ts diff --git a/e2e/tests/pages/alcs/applicant-info-page/land-use-section.ts b/e2e/pages/alcs/applicant-info-page/land-use-section.ts similarity index 100% rename from e2e/tests/pages/alcs/applicant-info-page/land-use-section.ts rename to e2e/pages/alcs/applicant-info-page/land-use-section.ts diff --git a/e2e/tests/pages/alcs/applicant-info-page/optional-documents-section.ts b/e2e/pages/alcs/applicant-info-page/optional-documents-section.ts similarity index 100% rename from e2e/tests/pages/alcs/applicant-info-page/optional-documents-section.ts rename to e2e/pages/alcs/applicant-info-page/optional-documents-section.ts diff --git a/e2e/tests/pages/alcs/applicant-info-page/other-owned-parcels-section.ts b/e2e/pages/alcs/applicant-info-page/other-owned-parcels-section.ts similarity index 100% rename from e2e/tests/pages/alcs/applicant-info-page/other-owned-parcels-section.ts rename to e2e/pages/alcs/applicant-info-page/other-owned-parcels-section.ts diff --git a/e2e/tests/pages/alcs/applicant-info-page/parcels-section.ts b/e2e/pages/alcs/applicant-info-page/parcels-section.ts similarity index 100% rename from e2e/tests/pages/alcs/applicant-info-page/parcels-section.ts rename to e2e/pages/alcs/applicant-info-page/parcels-section.ts diff --git a/e2e/tests/pages/alcs/applicant-info-page/primary-contact-section.ts b/e2e/pages/alcs/applicant-info-page/primary-contact-section.ts similarity index 100% rename from e2e/tests/pages/alcs/applicant-info-page/primary-contact-section.ts rename to e2e/pages/alcs/applicant-info-page/primary-contact-section.ts diff --git a/e2e/tests/pages/alcs/applicant-info-page/tur-proposal-section.ts b/e2e/pages/alcs/applicant-info-page/tur-proposal-section.ts similarity index 100% rename from e2e/tests/pages/alcs/applicant-info-page/tur-proposal-section.ts rename to e2e/pages/alcs/applicant-info-page/tur-proposal-section.ts diff --git a/e2e/tests/pages/alcs/details-navigation.ts b/e2e/pages/alcs/details-navigation.ts similarity index 100% rename from e2e/tests/pages/alcs/details-navigation.ts rename to e2e/pages/alcs/details-navigation.ts diff --git a/e2e/tests/pages/alcs/home-page.ts b/e2e/pages/alcs/home-page.ts similarity index 100% rename from e2e/tests/pages/alcs/home-page.ts rename to e2e/pages/alcs/home-page.ts diff --git a/e2e/tests/pages/inbox-page.ts b/e2e/pages/inbox-page.ts similarity index 100% rename from e2e/tests/pages/inbox-page.ts rename to e2e/pages/inbox-page.ts diff --git a/e2e/tests/pages/portal-login-page.ts b/e2e/pages/portal-login-page.ts similarity index 100% rename from e2e/tests/pages/portal-login-page.ts rename to e2e/pages/portal-login-page.ts diff --git a/e2e/tests/pages/portal/government-page.ts b/e2e/pages/portal/government-page.ts similarity index 100% rename from e2e/tests/pages/portal/government-page.ts rename to e2e/pages/portal/government-page.ts diff --git a/e2e/tests/pages/portal/inbox-page.ts b/e2e/pages/portal/inbox-page.ts similarity index 100% rename from e2e/tests/pages/portal/inbox-page.ts rename to e2e/pages/portal/inbox-page.ts diff --git a/e2e/tests/pages/portal/land-use-page.ts b/e2e/pages/portal/land-use-page.ts similarity index 100% rename from e2e/tests/pages/portal/land-use-page.ts rename to e2e/pages/portal/land-use-page.ts diff --git a/e2e/tests/pages/portal/optional-attachments-page.ts b/e2e/pages/portal/optional-attachments-page.ts similarity index 100% rename from e2e/tests/pages/portal/optional-attachments-page.ts rename to e2e/pages/portal/optional-attachments-page.ts diff --git a/e2e/tests/pages/portal/other-parcels-page.ts b/e2e/pages/portal/other-parcels-page.ts similarity index 100% rename from e2e/tests/pages/portal/other-parcels-page.ts rename to e2e/pages/portal/other-parcels-page.ts diff --git a/e2e/tests/pages/portal/parcels-page.ts b/e2e/pages/portal/parcels-page.ts similarity index 100% rename from e2e/tests/pages/portal/parcels-page.ts rename to e2e/pages/portal/parcels-page.ts diff --git a/e2e/tests/pages/portal/portal-login-page.ts b/e2e/pages/portal/portal-login-page.ts similarity index 100% rename from e2e/tests/pages/portal/portal-login-page.ts rename to e2e/pages/portal/portal-login-page.ts diff --git a/e2e/tests/pages/portal/portal-steps-navigation.ts b/e2e/pages/portal/portal-steps-navigation.ts similarity index 100% rename from e2e/tests/pages/portal/portal-steps-navigation.ts rename to e2e/pages/portal/portal-steps-navigation.ts diff --git a/e2e/tests/pages/portal/primary-contact-page.ts b/e2e/pages/portal/primary-contact-page.ts similarity index 100% rename from e2e/tests/pages/portal/primary-contact-page.ts rename to e2e/pages/portal/primary-contact-page.ts diff --git a/e2e/tests/pages/portal/review-and-submit-page/government-section.ts b/e2e/pages/portal/review-and-submit-page/government-section.ts similarity index 100% rename from e2e/tests/pages/portal/review-and-submit-page/government-section.ts rename to e2e/pages/portal/review-and-submit-page/government-section.ts diff --git a/e2e/tests/pages/portal/review-and-submit-page/land-use-section.ts b/e2e/pages/portal/review-and-submit-page/land-use-section.ts similarity index 100% rename from e2e/tests/pages/portal/review-and-submit-page/land-use-section.ts rename to e2e/pages/portal/review-and-submit-page/land-use-section.ts diff --git a/e2e/tests/pages/portal/review-and-submit-page/optional-documents-section.ts b/e2e/pages/portal/review-and-submit-page/optional-documents-section.ts similarity index 100% rename from e2e/tests/pages/portal/review-and-submit-page/optional-documents-section.ts rename to e2e/pages/portal/review-and-submit-page/optional-documents-section.ts diff --git a/e2e/tests/pages/portal/review-and-submit-page/other-owned-parcels-section.ts b/e2e/pages/portal/review-and-submit-page/other-owned-parcels-section.ts similarity index 100% rename from e2e/tests/pages/portal/review-and-submit-page/other-owned-parcels-section.ts rename to e2e/pages/portal/review-and-submit-page/other-owned-parcels-section.ts diff --git a/e2e/tests/pages/portal/review-and-submit-page/parcels-section.ts b/e2e/pages/portal/review-and-submit-page/parcels-section.ts similarity index 100% rename from e2e/tests/pages/portal/review-and-submit-page/parcels-section.ts rename to e2e/pages/portal/review-and-submit-page/parcels-section.ts diff --git a/e2e/tests/pages/portal/review-and-submit-page/primary-contact-section.ts b/e2e/pages/portal/review-and-submit-page/primary-contact-section.ts similarity index 100% rename from e2e/tests/pages/portal/review-and-submit-page/primary-contact-section.ts rename to e2e/pages/portal/review-and-submit-page/primary-contact-section.ts diff --git a/e2e/tests/pages/portal/review-and-submit-page/review-and-submit-page.ts b/e2e/pages/portal/review-and-submit-page/review-and-submit-page.ts similarity index 100% rename from e2e/tests/pages/portal/review-and-submit-page/review-and-submit-page.ts rename to e2e/pages/portal/review-and-submit-page/review-and-submit-page.ts diff --git a/e2e/tests/pages/portal/review-and-submit-page/tur-proposal-section.ts b/e2e/pages/portal/review-and-submit-page/tur-proposal-section.ts similarity index 100% rename from e2e/tests/pages/portal/review-and-submit-page/tur-proposal-section.ts rename to e2e/pages/portal/review-and-submit-page/tur-proposal-section.ts diff --git a/e2e/tests/pages/portal/submission-success-page.ts b/e2e/pages/portal/submission-success-page.ts similarity index 100% rename from e2e/tests/pages/portal/submission-success-page.ts rename to e2e/pages/portal/submission-success-page.ts diff --git a/e2e/tests/pages/portal/tur-proposal-page.ts b/e2e/pages/portal/tur-proposal-page.ts similarity index 100% rename from e2e/tests/pages/portal/tur-proposal-page.ts rename to e2e/pages/portal/tur-proposal-page.ts diff --git a/e2e/tests/tur.spec.ts b/e2e/tests/tur.spec.ts index edf579ec6b..df25b39b47 100644 --- a/e2e/tests/tur.spec.ts +++ b/e2e/tests/tur.spec.ts @@ -1,20 +1,20 @@ import { test } from '@playwright/test'; -import { PortalLoginPage } from './pages/portal/portal-login-page'; -import { ApplicationType, InboxPage } from './pages/portal/inbox-page'; -import { PortalStepsNavigation } from './pages/portal/portal-steps-navigation'; -import { OwnerType, ParcelType, ParcelsPage } from './pages/portal/parcels-page'; -import { OtherParcelsPage } from './pages/portal/other-parcels-page'; -import { PrimaryContactPage, PrimaryContactType } from './pages/portal/primary-contact-page'; -import { GovernmentPage } from './pages/portal/government-page'; -import { Direction, LandUsePage, LandUseType } from './pages/portal/land-use-page'; -import { TURProposalPage } from './pages/portal/tur-proposal-page'; -import { OptionalAttachmentType, OptionalAttachmentsPage } from './pages/portal/optional-attachments-page'; -import { ReviewAndSubmitPage } from './pages/portal/review-and-submit-page/review-and-submit-page'; -import { SubmissionSuccessPage } from './pages/portal/submission-success-page'; -import { ALCSLoginPage } from './pages/alcs/alcs-login-page'; -import { ALCSHomePage } from './pages/alcs/home-page'; -import { ALCSDetailsNavigation } from './pages/alcs/details-navigation'; -import { ALCSApplicantInfoPage } from './pages/alcs/applicant-info-page/applicant-info-page'; +import { PortalLoginPage } from '../pages/portal/portal-login-page'; +import { ApplicationType, InboxPage } from '../pages/portal/inbox-page'; +import { PortalStepsNavigation } from '../pages/portal/portal-steps-navigation'; +import { OwnerType, ParcelType, ParcelsPage } from '../pages/portal/parcels-page'; +import { OtherParcelsPage } from '../pages/portal/other-parcels-page'; +import { PrimaryContactPage, PrimaryContactType } from '../pages/portal/primary-contact-page'; +import { GovernmentPage } from '../pages/portal/government-page'; +import { Direction, LandUsePage, LandUseType } from '../pages/portal/land-use-page'; +import { TURProposalPage } from '../pages/portal/tur-proposal-page'; +import { OptionalAttachmentType, OptionalAttachmentsPage } from '../pages/portal/optional-attachments-page'; +import { ReviewAndSubmitPage } from '../pages/portal/review-and-submit-page/review-and-submit-page'; +import { SubmissionSuccessPage } from '../pages/portal/submission-success-page'; +import { ALCSLoginPage } from '../pages/alcs/alcs-login-page'; +import { ALCSHomePage } from '../pages/alcs/home-page'; +import { ALCSDetailsNavigation } from '../pages/alcs/details-navigation'; +import { ALCSApplicantInfoPage } from '../pages/alcs/applicant-info-page/applicant-info-page'; test.describe.serial('Portal TUR submission and ALCS applicant info flow', () => { const parcels = [ From a3ef722b0f9886b43a916046f6bea2681472b4c5 Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Thu, 2 May 2024 17:21:12 -0700 Subject: [PATCH 026/103] Fix portal login URL --- e2e/pages/portal/portal-login-page.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/e2e/pages/portal/portal-login-page.ts b/e2e/pages/portal/portal-login-page.ts index 85210ab4d1..72239758bb 100644 --- a/e2e/pages/portal/portal-login-page.ts +++ b/e2e/pages/portal/portal-login-page.ts @@ -2,13 +2,15 @@ import { type Locator, type Page } from '@playwright/test'; export class PortalLoginPage { readonly page: Page; + readonly baseUrl: string; readonly loginButton: Locator; readonly userIdTextbox: Locator; readonly passwordTextbox: Locator; readonly continueButton: Locator; - constructor(page: Page) { + constructor(page: Page, baseUrl: string) { this.page = page; + this.baseUrl = baseUrl; this.loginButton = page.getByRole('button', { name: 'Portal Login' }); // There is an error with the username label on BCeID page this.userIdTextbox = page.getByRole('textbox').nth(0); @@ -17,7 +19,7 @@ export class PortalLoginPage { } async goto() { - await this.page.goto('/'); + await this.page.goto(this.baseUrl); } async logIn(username: string, password: string) { From 39c3ab8d88d3c3be6b692b31197000b6f98371ff Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Thu, 2 May 2024 17:48:33 -0700 Subject: [PATCH 027/103] Fix portal login URL (for real this time) --- e2e/pages/portal-login-page.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/e2e/pages/portal-login-page.ts b/e2e/pages/portal-login-page.ts index 85210ab4d1..72239758bb 100644 --- a/e2e/pages/portal-login-page.ts +++ b/e2e/pages/portal-login-page.ts @@ -2,13 +2,15 @@ import { type Locator, type Page } from '@playwright/test'; export class PortalLoginPage { readonly page: Page; + readonly baseUrl: string; readonly loginButton: Locator; readonly userIdTextbox: Locator; readonly passwordTextbox: Locator; readonly continueButton: Locator; - constructor(page: Page) { + constructor(page: Page, baseUrl: string) { this.page = page; + this.baseUrl = baseUrl; this.loginButton = page.getByRole('button', { name: 'Portal Login' }); // There is an error with the username label on BCeID page this.userIdTextbox = page.getByRole('textbox').nth(0); @@ -17,7 +19,7 @@ export class PortalLoginPage { } async goto() { - await this.page.goto('/'); + await this.page.goto(this.baseUrl); } async logIn(username: string, password: string) { From ba20fb00c5ed9a7fe0e6236c71ed634cc4afedc4 Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Thu, 2 May 2024 17:50:44 -0700 Subject: [PATCH 028/103] Clean up comments --- e2e/pages/portal/optional-attachments-page.ts | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/e2e/pages/portal/optional-attachments-page.ts b/e2e/pages/portal/optional-attachments-page.ts index 4f20756ba1..fd7ba2c759 100644 --- a/e2e/pages/portal/optional-attachments-page.ts +++ b/e2e/pages/portal/optional-attachments-page.ts @@ -1,26 +1,5 @@ import { type Locator, type Page } from '@playwright/test'; -// // Step 7: Optional attachments -// // File upload first file -// const optionalFile1ChooserPromise = page.waitForEvent('filechooser'); -// await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); -// const optionalFile1Chooser = await optionalFile1ChooserPromise; -// optionalFile1Chooser.setFiles('data/temp.txt'); -// await page.getByPlaceholder('Select a type').nth(0).click(); -// await page.getByText('Professional Report').click(); -// await page.getByPlaceholder('Type description').nth(0).fill('Desc'); - -// // File upload second file -// const optionalFile2ChooserPromise = page.waitForEvent('filechooser'); -// await page.getByRole('button', { name: 'Choose file to Upload', exact: true }).click(); -// const optionalFile2Chooser = await optionalFile2ChooserPromise; -// optionalFile2Chooser.setFiles('data/temp.txt'); -// await page.getByPlaceholder('Select a type').nth(1).click(); -// await page.getByText('Site Photo').click(); -// await page.getByPlaceholder('Type description').nth(1).fill('Desc'); - -// await page.getByText('Review & Submit').click(); - export enum OptionalAttachmentType { SitePhoto = 'Site Photo', ProfessionalReport = 'Professional Report', From 2bef60b7c90c35f1d97fa7ece30898449e56207a Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Thu, 2 May 2024 17:52:08 -0700 Subject: [PATCH 029/103] Clean up old portal pages in wrong directory --- e2e/pages/inbox-page.ts | 53 ---------------------------------- e2e/pages/portal-login-page.ts | 31 -------------------- 2 files changed, 84 deletions(-) delete mode 100644 e2e/pages/inbox-page.ts delete mode 100644 e2e/pages/portal-login-page.ts diff --git a/e2e/pages/inbox-page.ts b/e2e/pages/inbox-page.ts deleted file mode 100644 index 35f273fe14..0000000000 --- a/e2e/pages/inbox-page.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect, type Locator, type Page } from '@playwright/test'; - -export enum SubmissionType { - Application = 'Application', - NoticeOfIntent = 'Notice of Intent', - SRW = 'Notification of Statutory Right of Way (SRW)', -} - -export enum ApplicationType { - Inclusion = 'Include Land into the ALR', - NARU = 'Non-Adhering Residential Use within the ALR', - POFO = 'Placement of Fill within the ALR', - PFRS = 'Removal of Soil (Extraction) and Placement of Fill within the ALR', - ROSO = 'Removal of Soil (Extraction) within the ALR', - NFU = 'Non-Farm Uses within the ALR', - Subdivision = 'Subdivide Land in the ALR', - TUR = 'Transportation, Utility, or Recreational Trail Uses within the ALR', - Exclusion = 'Exclude Land from the ALR', - Covenant = 'Register a Restrictive Covenant within the ALR', -} - -export class InboxPage { - readonly page: Page; - readonly startCreateButton: Locator; - readonly nextButton: Locator; - readonly finishCreateButton: Locator; - readonly createNewDialog: Locator; - - constructor(page: Page) { - this.page = page; - this.startCreateButton = page.getByRole('button', { name: '+ Create New' }); - this.nextButton = page.getByRole('button', { name: 'Next' }); - this.finishCreateButton = page.getByRole('button', { name: 'create' }); - this.createNewDialog = page.getByRole('dialog', { name: 'Create New' }); - } - - async createApplication(type: ApplicationType) { - await this.startCreateButton.click(); - await this.setSubmissionType(SubmissionType.Application); - await this.nextButton.click(); - await this.setApplicationType(type); - await this.finishCreateButton.click(); - await expect(this.createNewDialog).toBeHidden(); - } - - async setSubmissionType(type: SubmissionType) { - await this.page.getByRole('radio', { name: type }).check(); - } - - async setApplicationType(type: ApplicationType) { - await this.page.getByRole('radio', { name: type }).click(); - } -} diff --git a/e2e/pages/portal-login-page.ts b/e2e/pages/portal-login-page.ts deleted file mode 100644 index 72239758bb..0000000000 --- a/e2e/pages/portal-login-page.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type Locator, type Page } from '@playwright/test'; - -export class PortalLoginPage { - readonly page: Page; - readonly baseUrl: string; - readonly loginButton: Locator; - readonly userIdTextbox: Locator; - readonly passwordTextbox: Locator; - readonly continueButton: Locator; - - constructor(page: Page, baseUrl: string) { - this.page = page; - this.baseUrl = baseUrl; - this.loginButton = page.getByRole('button', { name: 'Portal Login' }); - // There is an error with the username label on BCeID page - this.userIdTextbox = page.getByRole('textbox').nth(0); - this.passwordTextbox = page.getByLabel('Password'); - this.continueButton = page.getByRole('button', { name: 'Continue' }); - } - - async goto() { - await this.page.goto(this.baseUrl); - } - - async logIn(username: string, password: string) { - await this.loginButton.click(); - await this.userIdTextbox.fill(username); - await this.passwordTextbox.fill(password); - await this.continueButton.click(); - } -} From b88683e7d3aae5df6753078dc52df953c83329cc Mon Sep 17 00:00:00 2001 From: Mekhti Date: Mon, 6 May 2024 09:19:57 -0700 Subject: [PATCH 030/103] tidy --- .../lfng_status_adjustment.py | 11 ++-- .../sql/oats_latest_lfng_status_back.sql | 38 ------------- .../sql/oats_latest_lfng_status_count.sql | 57 ++++++++++++++++--- 3 files changed, 54 insertions(+), 52 deletions(-) delete mode 100644 bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_back.sql diff --git a/bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py b/bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py index 7b42a538a7..ad45746d37 100644 --- a/bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py +++ b/bin/migrate-oats-data/applications/lfng_status_adjustment/lfng_status_adjustment.py @@ -13,7 +13,7 @@ @inject_conn_pool def readjust_lfng_statuses(conn=None, batch_size=BATCH_UPLOAD_SIZE): """ - TODO copy description from JIRA + This function is responsible for resetting LFNG status on application to the final state from OATS. Args: conn (psycopg2.extensions.connection): PostgreSQL database connection. Provided by the decorator. @@ -56,7 +56,7 @@ def readjust_lfng_statuses(conn=None, batch_size=BATCH_UPLOAD_SIZE): break try: processed_applications_count = _process_statuses_for_update( - conn, batch_size, cursor, rows + conn, cursor, rows ) successful_updates_count = ( @@ -78,7 +78,7 @@ def readjust_lfng_statuses(conn=None, batch_size=BATCH_UPLOAD_SIZE): ) -def _process_statuses_for_update(conn, batch_size, cursor, rows): +def _process_statuses_for_update(conn, cursor, rows): update_statements = [] grouped_by_fn = [] @@ -98,7 +98,6 @@ def _process_statuses_for_update(conn, batch_size, cursor, rows): grouped_by_fn.append(row) combined_statements = "; ".join(update_statements) - # this is useful for debugging # logger.debug("combined_statements: %s", combined_statements) cursor.execute(combined_statements) conn.commit() @@ -154,10 +153,10 @@ def _compile_update_statement( """ if alcs_status: - logger.debug(f"reset {str_statuses_to_reset}") + # logger.debug(f"reset {str_statuses_to_reset}") return reset_statuses_query else: - logger.debug(f"{str_statuses_to_reset} and set {alcs_target_status}") + # logger.debug(f"{str_statuses_to_reset} and set {alcs_target_status}") date = add_timezone_and_keep_date_part(oats_status["completion_date"]) return f""" {reset_statuses_query} diff --git a/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_back.sql b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_back.sql deleted file mode 100644 index 5f2ca65b49..0000000000 --- a/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_back.sql +++ /dev/null @@ -1,38 +0,0 @@ - -WITH submitted_under_review AS ( - SELECT astss.submission_uuid AS initial_sub_uuid FROM alcs.application_submission_to_submission_status astss - JOIN alcs.application_submission as2 ON as2."uuid" = astss.submission_uuid AND as2.audit_created_by='oats_etl' AND as2.audit_updated_by IS NULL - WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('SUBG', 'REVG') - GROUP BY astss.submission_uuid -) -, returned_incomplete_refused AS ( - SELECT as2.file_number, string_agg(astss.status_type_code, ', ') - FROM submitted_under_review sur - JOIN alcs.application_submission_to_submission_status astss ON astss.submission_uuid = sur.initial_sub_uuid - JOIN alcs.application_submission as2 ON as2.uuid = astss.submission_uuid - WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('WRNG', 'INCM', 'RFFG', 'SUBG', 'REVG') - GROUP BY as2.file_number -) -, oats_accomplishments AS ( - SELECT rir.*, oaac.* FROM oats.oats_accomplishments oaac - JOIN returned_incomplete_refused AS rir ON rir.file_number::bigint = oaac.alr_application_id - WHERE oaac.accomplishment_code IN ('LRF', 'SLG', 'WLG', 'ULG', 'LGI') -) --- ranked_statuses will select the latest status based on max completion_date, then when_updated, then when_created for all records per file_number -, ranked_statuses AS ( - SELECT *, - ROW_NUMBER() OVER (PARTITION BY file_number ORDER BY COALESCE(completion_date, '0001-01-01') DESC, COALESCE(when_updated, '0001-01-01') DESC, COALESCE(when_created, '0001-01-01') DESC) AS rn - FROM oats_accomplishments -) -, latest_oats_lfng_status AS ( - SELECT alr_application_id, accomplishment_code, completion_date, when_created, when_updated, revision_count FROM ranked_statuses - WHERE rn = 1 - ORDER BY file_number::bigint -) -SELECT as2.uuid, lols.alr_application_id, accomplishment_code, lols.completion_date, when_created, when_updated, astss.status_type_code, astss.effective_date, asst.weight -FROM alcs.application_submission_to_submission_status astss -JOIN alcs.application_submission as2 ON as2.uuid = astss.submission_uuid -JOIN alcs.application_submission_status_type asst ON asst.code = astss.status_type_code -JOIN latest_oats_lfng_status lols ON lols.alr_application_id = as2.file_number::bigint -WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('WRNG', 'INCM', 'RFFG', 'SUBG', 'REVG') -ORDER BY lols.alr_application_id \ No newline at end of file diff --git a/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql index d004f511ff..e9a328918d 100644 --- a/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql +++ b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql @@ -1,5 +1,45 @@ --- select latest OATS government status -WITH submitted_under_review AS ( +WITH when_updated_grouped AS ( + SELECT + a.when_updated, + a.alr_application_id, + a.accomplishment_code, + COUNT(*) OVER (PARTITION BY a.when_updated, a.alr_application_id) as cnt + FROM + oats.oats_accomplishments a + WHERE a.when_updated IS NOT NULL +), when_updated_with_status AS ( + SELECT + when_updated, + alr_application_id, + accomplishment_code, + cnt + FROM + when_updated_grouped + WHERE + cnt > 1 +), completion_grouped AS ( + SELECT + wuws.when_updated, + wuws.alr_application_id, + wuws.accomplishment_code, + COUNT(*) OVER (PARTITION BY oa.completion_date, wuws.alr_application_id) as cnt + FROM + when_updated_with_status wuws + JOIN oats.oats_accomplishments oa ON oa.alr_application_id = wuws.alr_application_id AND oa.accomplishment_code = wuws.accomplishment_code + WHERE oa.completion_date IS NOT NULL +), completion_with_status AS ( + SELECT + when_updated, + alr_application_id, + accomplishment_code, + cnt + FROM + completion_grouped + WHERE + cnt > 1 +), +alr_applications_to_exclude AS ( SELECT alr_application_id FROM completion_with_status) +, submitted_under_review AS ( SELECT astss.submission_uuid AS initial_sub_uuid FROM alcs.application_submission_to_submission_status astss JOIN alcs.application_submission as2 ON as2."uuid" = astss.submission_uuid AND as2.audit_created_by='oats_etl' AND as2.audit_updated_by IS NULL WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('SUBG', 'REVG') @@ -18,15 +58,16 @@ WITH submitted_under_review AS ( JOIN returned_incomplete_refused AS rir ON rir.file_number::bigint = oaac.alr_application_id WHERE oaac.accomplishment_code IN ('LRF', 'SLG', 'WLG', 'ULG', 'LGI') ) --- ranked_statuses will select the latest status based on max between when_created or when_updated for all records per file_number +-- ranked_statuses will select the latest status based on max completion_date, then when_updated, then when_created for all records per file_number , ranked_statuses AS ( - SELECT *, GREATEST(COALESCE(when_created, '0001-01-01'), COALESCE(when_updated, '0001-01-01')) AS max_date, ROW_NUMBER() OVER (PARTITION BY file_number ORDER BY GREATEST(COALESCE(when_created, '0001-01-01'), COALESCE(when_updated, '0001-01-01')) DESC) AS rn + SELECT *, + ROW_NUMBER() OVER (PARTITION BY file_number ORDER BY COALESCE(completion_date, '0001-01-01') DESC, COALESCE(when_updated, '0001-01-01') DESC, COALESCE(when_created, '0001-01-01') DESC) AS rn FROM oats_accomplishments ) , latest_oats_lfng_status AS ( - SELECT * FROM ranked_statuses - WHERE - rn = 1 + SELECT alr_application_id, accomplishment_code, completion_date, when_created, when_updated, revision_count FROM ranked_statuses + WHERE rn = 1 ORDER BY file_number::bigint ) -SELECT count(*) FROM latest_oats_lfng_status +SELECT count(*) +FROM latest_oats_lfng_status; From 5fff3a3e55b94c8ce937ed4341360805392c96b4 Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Mon, 6 May 2024 13:51:38 -0700 Subject: [PATCH 031/103] Check submission status after submitted Check application status changes to 'Submitted to ALC' after being submitted. --- e2e/pages/portal/submission-success-page.ts | 28 +++++++++++++++++++ e2e/tests/tur.spec.ts | 6 ++-- ...view-application-submission.component.html | 2 +- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/e2e/pages/portal/submission-success-page.ts b/e2e/pages/portal/submission-success-page.ts index 378ce1143a..70354874f1 100644 --- a/e2e/pages/portal/submission-success-page.ts +++ b/e2e/pages/portal/submission-success-page.ts @@ -1,12 +1,32 @@ import { expect, type Locator, type Page } from '@playwright/test'; +export enum ApplicationStatus { + UnderReviewByALC = 'Under Review by ALC', + ReceivedByALC = 'Received By ALC', + SubmittedToALCIncomplete = 'Submitted to ALC - Incomplete', + SubmittedToALC = 'Submitted to ALC', + UnderReviewByLFNG = 'Under Review by L/FNG', + SubmittedToLFNG = 'Submitted to L/FNG', + InProgress = 'In Progress', + LFNGReturnedAsIncomplete = 'L/FNG Returned as Incomplete', + WrongLFNG = 'Wrong L/FNG', + ALCReturnedToLFNG = 'ALC Returned to L/FNG', + Cancelled = 'Cancelled', + LFNGRefusedToForward = 'L/FNG Refused to Forward', + DecisionReleased = 'Decision Released', +} + export class SubmissionSuccessPage { readonly page: Page; readonly heading: Locator; + readonly viewSubmissionButton: Locator; + readonly applicationStatusText: Locator; constructor(page: Page) { this.page = page; this.heading = page.getByRole('heading', { name: 'Application ID' }); + this.viewSubmissionButton = page.getByRole('button', { name: 'view submission' }); + this.applicationStatusText = page.getByTestId('application-status'); } async fileId(): Promise { @@ -23,4 +43,12 @@ export class SubmissionSuccessPage { // Should never return empty string, but necessary to appease TypeScript return fileId; } + + async viewSubmission() { + await this.viewSubmissionButton.click(); + } + + async expectApplicationStatus(status: ApplicationStatus) { + await expect(this.applicationStatusText).toHaveText(status); + } } diff --git a/e2e/tests/tur.spec.ts b/e2e/tests/tur.spec.ts index df25b39b47..30ff74ad95 100644 --- a/e2e/tests/tur.spec.ts +++ b/e2e/tests/tur.spec.ts @@ -10,7 +10,7 @@ import { Direction, LandUsePage, LandUseType } from '../pages/portal/land-use-pa import { TURProposalPage } from '../pages/portal/tur-proposal-page'; import { OptionalAttachmentType, OptionalAttachmentsPage } from '../pages/portal/optional-attachments-page'; import { ReviewAndSubmitPage } from '../pages/portal/review-and-submit-page/review-and-submit-page'; -import { SubmissionSuccessPage } from '../pages/portal/submission-success-page'; +import { ApplicationStatus, SubmissionSuccessPage } from '../pages/portal/submission-success-page'; import { ALCSLoginPage } from '../pages/alcs/alcs-login-page'; import { ALCSHomePage } from '../pages/alcs/home-page'; import { ALCSDetailsNavigation } from '../pages/alcs/details-navigation'; @@ -50,8 +50,8 @@ test.describe.serial('Portal TUR submission and ALCS applicant info flow', () => ], }, ]; - const otherParcelsDescription = 'Other parcels description'; const hasOtherParcels = true; + const otherParcelsDescription = 'Other parcels description'; const primaryContactType = PrimaryContactType.ThirdParty; const thirdPartPrimaryContact = { firstName: 'Person', @@ -183,6 +183,8 @@ test.describe.serial('Portal TUR submission and ALCS applicant info flow', () => const submissionSuccessPage = new SubmissionSuccessPage(page); submittedFileId = await submissionSuccessPage.fileId(); + await submissionSuccessPage.viewSubmission(); + await submissionSuccessPage.expectApplicationStatus(ApplicationStatus.SubmittedToALC); }); test('submission data should appear in ALCS applicant info', async ({ page }) => { diff --git a/portal-frontend/src/app/features/applications/view-submission/view-application-submission.component.html b/portal-frontend/src/app/features/applications/view-submission/view-application-submission.component.html index 40fa941cf2..052186e600 100644 --- a/portal-frontend/src/app/features/applications/view-submission/view-application-submission.component.html +++ b/portal-frontend/src/app/features/applications/view-submission/view-application-submission.component.html @@ -9,7 +9,7 @@

Application ID: {{ application.fileNumber }} | {{ applic

Application Status
- {{ application.status.label }} + {{ application.status.label }}
From dc9036afc4f4b65f1b1238beced8ac0d2d043c3b Mon Sep 17 00:00:00 2001 From: Mekhti Date: Mon, 6 May 2024 15:13:35 -0700 Subject: [PATCH 032/103] new requirement to exclude submission where no LFNG review or it was updated --- .../lfng_status_adjustment/sql/oats_latest_lfng_status.sql | 3 ++- .../sql/oats_latest_lfng_status_count.sql | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status.sql b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status.sql index fb05be4ad7..f7701ddc05 100644 --- a/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status.sql +++ b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status.sql @@ -42,7 +42,8 @@ alr_applications_to_exclude AS ( SELECT alr_application_id FROM completion_with_ , submitted_under_review AS ( SELECT astss.submission_uuid AS initial_sub_uuid FROM alcs.application_submission_to_submission_status astss JOIN alcs.application_submission as2 ON as2."uuid" = astss.submission_uuid AND as2.audit_created_by='oats_etl' AND as2.audit_updated_by IS NULL - WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('SUBG', 'REVG') + LEFT JOIN alcs.application_submission_review asr ON asr.application_file_number = as2.file_number + WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('SUBG', 'REVG') AND ( asr.uuid IS NULL OR asr.audit_created_by='oats_etl' AND asr.audit_updated_by IS NULL) GROUP BY astss.submission_uuid ) , returned_incomplete_refused AS ( diff --git a/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql index e9a328918d..b55cf45817 100644 --- a/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql +++ b/bin/migrate-oats-data/applications/lfng_status_adjustment/sql/oats_latest_lfng_status_count.sql @@ -42,7 +42,8 @@ alr_applications_to_exclude AS ( SELECT alr_application_id FROM completion_with_ , submitted_under_review AS ( SELECT astss.submission_uuid AS initial_sub_uuid FROM alcs.application_submission_to_submission_status astss JOIN alcs.application_submission as2 ON as2."uuid" = astss.submission_uuid AND as2.audit_created_by='oats_etl' AND as2.audit_updated_by IS NULL - WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('SUBG', 'REVG') + LEFT JOIN alcs.application_submission_review asr ON asr.application_file_number = as2.file_number + WHERE astss.effective_date IS NOT NULL AND astss.status_type_code IN ('SUBG', 'REVG') AND ( asr.uuid IS NULL OR asr.audit_created_by='oats_etl' AND asr.audit_updated_by IS NULL) GROUP BY astss.submission_uuid ) , returned_incomplete_refused AS ( From 3f721cf70f6a866d23526f31360cd8a3436603ac Mon Sep 17 00:00:00 2001 From: Tristan Slater <1631008+trslater@users.noreply.github.com> Date: Mon, 6 May 2024 15:35:26 -0700 Subject: [PATCH 033/103] Check submission PDF in ALCS documents table --- .../application/application.component.html | 2 +- e2e/pages/alcs/details-navigation.ts | 10 +++++++++- e2e/pages/alcs/documents-page.ts | 19 +++++++++++++++++++ e2e/tests/tur.spec.ts | 6 ++++++ 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 e2e/pages/alcs/documents-page.ts diff --git a/alcs-frontend/src/app/features/application/application.component.html b/alcs-frontend/src/app/features/application/application.component.html index 82ccfb9d5c..b2f12fbb41 100644 --- a/alcs-frontend/src/app/features/application/application.component.html +++ b/alcs-frontend/src/app/features/application/application.component.html @@ -11,7 +11,7 @@ >