From 445ad4f137c0fe2f32c13f6cd5180d41aa3bddc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Zi=C4=99cina?= Date: Mon, 17 Nov 2025 07:47:22 +0100 Subject: [PATCH 01/72] feat: add listing file number (#5558) * feat: add listing file number * fix: hide listing file number when no jurisdiction selected * test: add tests for listing file number --- .../37_add_listing_file_number/migration.sql | 2 + api/prisma/schema.prisma | 1 + api/prisma/seed-staging.ts | 2 + api/src/dtos/listings/listing.dto.ts | 8 +++ .../enums/feature-flags/feature-flags-enum.ts | 6 +++ .../services/listing-csv-export.service.ts | 11 ++++ shared-helpers/src/types/backend-swagger.ts | 10 ++++ .../sections/ListingIntro.test.tsx | 50 +++++++++++++++++++ .../locale_overrides/general.json | 1 + .../sections/DetailListingIntro.tsx | 13 +++++ .../sections/ListingIntro.tsx | 20 ++++++++ sites/partners/src/lib/listings/formTypes.ts | 1 + 12 files changed, 125 insertions(+) create mode 100644 api/prisma/migrations/37_add_listing_file_number/migration.sql diff --git a/api/prisma/migrations/37_add_listing_file_number/migration.sql b/api/prisma/migrations/37_add_listing_file_number/migration.sql new file mode 100644 index 0000000000..41307ec55d --- /dev/null +++ b/api/prisma/migrations/37_add_listing_file_number/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "listings" ADD COLUMN "listing_file_number" TEXT; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 9cf4d0f9c0..8f4569498a 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -566,6 +566,7 @@ model Listings { amenities String? buildingTotalUnits Int? @map("building_total_units") developer String? + listingFileNumber String? @map("listing_file_number") householdSizeMax Int? @map("household_size_max") householdSizeMin Int? @map("household_size_min") neighborhood String? diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index 49b8576d50..9d4f3a6834 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -160,6 +160,7 @@ export const stagingSeed = async ( featureFlags: [ FeatureFlagEnum.enableAccessibilityFeatures, FeatureFlagEnum.enableHousingDeveloperOwner, + FeatureFlagEnum.enableListingFileNumber, FeatureFlagEnum.enableListingFiltering, FeatureFlagEnum.enableMarketingStatus, FeatureFlagEnum.enableMarketingStatusMonths, @@ -177,6 +178,7 @@ export const stagingSeed = async ( 'leasingAgentEmail', 'leasingAgentName', 'leasingAgentPhone', + 'listingFileNumber', 'listingImages', 'listingsBuildingAddress', 'name', diff --git a/api/src/dtos/listings/listing.dto.ts b/api/src/dtos/listings/listing.dto.ts index e3113e6807..ff4c438148 100644 --- a/api/src/dtos/listings/listing.dto.ts +++ b/api/src/dtos/listings/listing.dto.ts @@ -134,6 +134,14 @@ class Listing extends AbstractDTO { @ApiPropertyOptional() developer?: string; + @Expose() + @ValidateListingPublish('listingFileNumber', { + groups: [ValidationsGroupsEnum.default], + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + listingFileNumber?: string; + @Expose() @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() diff --git a/api/src/enums/feature-flags/feature-flags-enum.ts b/api/src/enums/feature-flags/feature-flags-enum.ts index 25779eae9b..e9fe89fbb0 100644 --- a/api/src/enums/feature-flags/feature-flags-enum.ts +++ b/api/src/enums/feature-flags/feature-flags-enum.ts @@ -18,6 +18,7 @@ export enum FeatureFlagEnum { enableIsVerified = 'enableIsVerified', enableLimitedHowDidYouHear = 'enableLimitedHowDidYouHear', enableListingFavoriting = 'enableListingFavoriting', + enableListingFileNumber = 'enableListingFileNumber', enableListingFiltering = 'enableListingFiltering', enableListingOpportunity = 'enableListingOpportunity', enableListingPagination = 'enableListingPagination', @@ -130,6 +131,11 @@ export const featureFlagMap: { description: 'When true, a Favorite button is shown for public listings and users can view their favorited listings', }, + { + name: FeatureFlagEnum.enableListingFileNumber, + description: + 'When true, partners can enter and export a listing file number', + }, { name: FeatureFlagEnum.enableListingFiltering, description: diff --git a/api/src/services/listing-csv-export.service.ts b/api/src/services/listing-csv-export.service.ts index bd4331ed7a..b165e65645 100644 --- a/api/src/services/listing-csv-export.service.ts +++ b/api/src/services/listing-csv-export.service.ts @@ -459,6 +459,17 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { ? 'Housing developer / owner' : 'Developer', }, + ...(doAnyJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableListingFileNumber, + ) + ? [ + { + path: 'listingFileNumber', + label: 'Listing File Number', + }, + ] + : []), { path: 'listingsBuildingAddress.street', label: 'Building Street Address', diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 7b3a811508..f3654fa018 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -3962,6 +3962,9 @@ export interface Listing { /** */ developer?: string + /** */ + listingFileNumber?: string + /** */ householdSizeMax?: number @@ -4652,6 +4655,9 @@ export interface ListingCreate { /** */ developer?: string + /** */ + listingFileNumber?: string + /** */ householdSizeMax?: number @@ -4995,6 +5001,9 @@ export interface ListingUpdate { /** */ developer?: string + /** */ + listingFileNumber?: string + /** */ householdSizeMax?: number @@ -7600,6 +7609,7 @@ export enum FeatureFlagEnum { "enableIsVerified" = "enableIsVerified", "enableLimitedHowDidYouHear" = "enableLimitedHowDidYouHear", "enableListingFavoriting" = "enableListingFavoriting", + "enableListingFileNumber" = "enableListingFileNumber", "enableListingFiltering" = "enableListingFiltering", "enableListingOpportunity" = "enableListingOpportunity", "enableListingPagination" = "enableListingPagination", diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx index 8c35ecd61e..4f021e0bef 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx @@ -74,6 +74,7 @@ describe("ListingIntro", () => { expect(screen.getByRole("textbox", { name: "Listing name *" })).toBeInTheDocument() expect(screen.queryByRole("combobox", { name: "Jurisdiction *" })).not.toBeInTheDocument() expect(screen.getByRole("textbox", { name: "Housing developer" })).toBeInTheDocument() + expect(screen.queryByRole("textbox", { name: "Listing file number" })).not.toBeInTheDocument() }) it("should render the ListingIntro section with multiple jurisdictions and required developer", async () => { @@ -112,6 +113,7 @@ describe("ListingIntro", () => { ) expect(screen.getByRole("textbox", { name: "Housing developer *" })).toBeInTheDocument() + expect(screen.queryByRole("textbox", { name: "Listing file number" })).not.toBeInTheDocument() }) it("should render appropriate text when housing developer owner feature flag is on", async () => { @@ -150,4 +152,52 @@ describe("ListingIntro", () => { expect(screen.getByRole("textbox", { name: "Housing developer / owner" })).toBeInTheDocument() expect(screen.queryByRole("textbox", { name: "Housing developer" })).not.toBeInTheDocument() }) + + it("should render listing file number field when feature flag is on after jurisdiction is selected", async () => { + document.cookie = "access-token-available=True" + server.use( + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res( + ctx.json({ + jurisdictions: [ + { + id: "JurisdictionA", + name: "jurisdictionWithJurisdictionAdmin", + featureFlags: [{ name: FeatureFlagEnum.enableListingFileNumber, active: true }], + }, + ], + }) + ) + }) + ) + + render( + + + + ) + + expect(screen.queryByRole("textbox", { name: "Listing file number" })).not.toBeInTheDocument() + + await userEvent.selectOptions( + screen.getByRole("combobox", { name: "Jurisdiction *" }), + screen.getByRole("option", { name: "JurisdictionA" }) + ) + + expect(screen.getByRole("textbox", { name: "Listing file number" })).toBeInTheDocument() + }) }) diff --git a/sites/partners/page_content/locale_overrides/general.json b/sites/partners/page_content/locale_overrides/general.json index 13288ace89..4518e03ea3 100644 --- a/sites/partners/page_content/locale_overrides/general.json +++ b/sites/partners/page_content/locale_overrides/general.json @@ -259,6 +259,7 @@ "listings.listingAvailabilityQuestion": "What is the listing availability?", "listings.listingIsAlreadyLive": "This listing is already live. Updates will affect the applicant experience on the housing portal.", "listings.listingName": "Listing name", + "listings.listingFileNumber": "Listing file number", "listings.listingPhoto": "Listing photo", "listings.listingStatus.active": "Open", "listings.listingStatus.changesRequested": "Changes requested", diff --git a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailListingIntro.tsx b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailListingIntro.tsx index ec3b778af8..e0feb88225 100644 --- a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailListingIntro.tsx +++ b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailListingIntro.tsx @@ -15,9 +15,22 @@ const DetailListingIntro = () => { FeatureFlagEnum.enableHousingDeveloperOwner, listing.jurisdictions.id ) + const enableListingFileNumber = doJurisdictionsHaveFeatureFlagOn( + FeatureFlagEnum.enableListingFileNumber, + listing.jurisdictions.id + ) return ( + {enableListingFileNumber && ( + + + + {getDetailFieldString(listing.listingFileNumber)} + + + + )} diff --git a/sites/partners/src/components/listings/PaperListingForm/sections/ListingIntro.tsx b/sites/partners/src/components/listings/PaperListingForm/sections/ListingIntro.tsx index 392069b73c..28cfa6ea9e 100644 --- a/sites/partners/src/components/listings/PaperListingForm/sections/ListingIntro.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/sections/ListingIntro.tsx @@ -38,6 +38,10 @@ const ListingIntro = (props: ListingIntroProps) => { FeatureFlagEnum.enableHousingDeveloperOwner, jurisdiction ) + const enableListingFileNumber = doJurisdictionsHaveFeatureFlagOn( + FeatureFlagEnum.enableListingFileNumber, + jurisdiction + ) const jurisdictionOptions: SelectOption[] = [ { label: "", value: "" }, @@ -55,6 +59,22 @@ const ListingIntro = (props: ListingIntroProps) => { heading={t("listings.sections.introTitle")} subheading={t("listings.sections.introSubtitle")} > + {enableListingFileNumber && jurisdiction && ( + + + + + + )} Date: Mon, 17 Nov 2025 07:51:14 +0100 Subject: [PATCH 02/72] feat: add additional neighborhood amenities (#5563) * feat: add additional neighborhood amenities * test: add new neighborhood amenities to listing service test * fix: switch senior center to senior centers --- .../migration.sql | 15 ++++++ api/prisma/schema.prisma | 12 +++++ api/prisma/seed-staging.ts | 6 +++ .../listing-neighborhood-amenities.dto.ts | 36 ++++++++++++++ .../services/listing-csv-export.service.ts | 24 ++++++++++ .../unit/services/listing.service.spec.ts | 48 +++++++++++++++++++ shared-helpers/src/locales/ar.json | 6 +++ shared-helpers/src/locales/bn.json | 6 +++ shared-helpers/src/locales/es.json | 6 +++ shared-helpers/src/locales/general.json | 6 +++ shared-helpers/src/locales/tl.json | 6 +++ shared-helpers/src/locales/vi.json | 6 +++ shared-helpers/src/locales/zh.json | 6 +++ shared-helpers/src/types/backend-swagger.ts | 24 ++++++++++ .../sections/NeighborhoodAmenities.test.tsx | 6 +++ 15 files changed, 213 insertions(+) create mode 100644 api/prisma/migrations/37_add_new_neighborhood_amenities/migration.sql diff --git a/api/prisma/migrations/37_add_new_neighborhood_amenities/migration.sql b/api/prisma/migrations/37_add_new_neighborhood_amenities/migration.sql new file mode 100644 index 0000000000..bc5e2d9292 --- /dev/null +++ b/api/prisma/migrations/37_add_new_neighborhood_amenities/migration.sql @@ -0,0 +1,15 @@ +-- AlterEnum +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'shoppingVenues'; +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'hospitals'; +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'seniorCenters'; +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'recreationalFacilities'; +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'playgrounds'; +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'busStops'; + +-- AlterTable +ALTER TABLE "listing_neighborhood_amenities" ADD COLUMN "bus_stops" TEXT, +ADD COLUMN "hospitals" TEXT, +ADD COLUMN "playgrounds" TEXT, +ADD COLUMN "recreational_facilities" TEXT, +ADD COLUMN "senior_centers" TEXT, +ADD COLUMN "shopping_venues" TEXT; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 8f4569498a..71f75363f2 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -1000,6 +1000,12 @@ model ListingNeighborhoodAmenities { parksAndCommunityCenters String? @map("parks_and_community_centers") schools String? publicTransportation String? @map("public_transportation") + shoppingVenues String? @map("shopping_venues") + hospitals String? + seniorCenters String? @map("senior_centers") + recreationalFacilities String? @map("recreational_facilities") + playgrounds String? + busStops String? @map("bus_stops") listings Listings? @@map("listing_neighborhood_amenities") @@ -1323,6 +1329,12 @@ enum NeighborhoodAmenitiesEnum { parksAndCommunityCenters pharmacies healthCareResources + shoppingVenues + hospitals + seniorCenters + recreationalFacilities + playgrounds + busStops @@map("neighborhood_amenities_enum") } diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index 9d4f3a6834..876d022989 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -170,6 +170,12 @@ export const stagingSeed = async ( visibleNeighborhoodAmenities: [ NeighborhoodAmenitiesEnum.groceryStores, NeighborhoodAmenitiesEnum.pharmacies, + NeighborhoodAmenitiesEnum.shoppingVenues, + NeighborhoodAmenitiesEnum.hospitals, + NeighborhoodAmenitiesEnum.seniorCenters, + NeighborhoodAmenitiesEnum.recreationalFacilities, + NeighborhoodAmenitiesEnum.playgrounds, + NeighborhoodAmenitiesEnum.busStops, ], requiredListingFields: [ diff --git a/api/src/dtos/listings/listing-neighborhood-amenities.dto.ts b/api/src/dtos/listings/listing-neighborhood-amenities.dto.ts index 7d131e7b09..9d2e70b1ed 100644 --- a/api/src/dtos/listings/listing-neighborhood-amenities.dto.ts +++ b/api/src/dtos/listings/listing-neighborhood-amenities.dto.ts @@ -39,4 +39,40 @@ export class ListingNeighborhoodAmenities { @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() healthCareResources?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + shoppingVenues?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + hospitals?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + seniorCenters?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + recreationalFacilities?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + playgrounds?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + busStops?: string | null; } diff --git a/api/src/services/listing-csv-export.service.ts b/api/src/services/listing-csv-export.service.ts index b165e65645..ffb3290f3c 100644 --- a/api/src/services/listing-csv-export.service.ts +++ b/api/src/services/listing-csv-export.service.ts @@ -854,6 +854,30 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { path: 'listingNeighborhoodAmenities.healthCareResources', label: 'Neighborhood Amenities - Health Care Resources', }, + [NeighborhoodAmenitiesEnum.shoppingVenues]: { + path: 'listingNeighborhoodAmenities.shoppingVenues', + label: 'Neighborhood Amenities - Shopping Venues', + }, + [NeighborhoodAmenitiesEnum.hospitals]: { + path: 'listingNeighborhoodAmenities.hospitals', + label: 'Neighborhood Amenities - Hospitals', + }, + [NeighborhoodAmenitiesEnum.seniorCenters]: { + path: 'listingNeighborhoodAmenities.seniorCenters', + label: 'Neighborhood Amenities - Senior Centers', + }, + [NeighborhoodAmenitiesEnum.recreationalFacilities]: { + path: 'listingNeighborhoodAmenities.recreationalFacilities', + label: 'Neighborhood Amenities - Recreational Facilities', + }, + [NeighborhoodAmenitiesEnum.playgrounds]: { + path: 'listingNeighborhoodAmenities.playgrounds', + label: 'Neighborhood Amenities - Playgrounds', + }, + [NeighborhoodAmenitiesEnum.busStops]: { + path: 'listingNeighborhoodAmenities.busStops', + label: 'Neighborhood Amenities - Bus Stops', + }, }; Object.keys(amenityHeaderMap).forEach((key) => { diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts index d8c97bfcdb..dacdc35366 100644 --- a/api/test/unit/services/listing.service.spec.ts +++ b/api/test/unit/services/listing.service.spec.ts @@ -573,6 +573,12 @@ describe('Testing listing service', () => { parksAndCommunityCenters: 'parks', schools: 'schools', publicTransportation: 'public transportation', + busStops: 'bus stops', + hospitals: 'hospitals', + playgrounds: 'playgrounds', + recreationalFacilities: 'recreational facilities', + seniorCenters: 'senior centers', + shoppingVenues: 'shopping venues', }, marketingType: undefined, }; @@ -3488,6 +3494,12 @@ describe('Testing listing service', () => { parksAndCommunityCenters: 'parks', schools: 'schools', publicTransportation: 'public transportation', + busStops: 'bus stops', + hospitals: 'hospitals', + playgrounds: 'playgrounds', + recreationalFacilities: 'recreational facilities', + seniorCenters: 'senior centers', + shoppingVenues: 'shopping venues', }, }, jurisdictions: { @@ -3990,6 +4002,12 @@ describe('Testing listing service', () => { parksAndCommunityCenters: 'parks', schools: 'schools', publicTransportation: 'public transportation', + busStops: 'bus stops', + hospitals: 'hospitals', + playgrounds: 'playgrounds', + recreationalFacilities: 'recreational facilities', + seniorCenters: 'senior centers', + shoppingVenues: 'shopping venues', }, }, jurisdictions: { @@ -4469,6 +4487,12 @@ describe('Testing listing service', () => { parksAndCommunityCenters: 'parks', schools: 'schools', publicTransportation: 'public transportation', + busStops: 'bus stops', + hospitals: 'hospitals', + playgrounds: 'playgrounds', + recreationalFacilities: 'recreational facilities', + seniorCenters: 'senior centers', + shoppingVenues: 'shopping venues', }; const calculatedUnitsAvailable = service.calculateUnitsAvailable( @@ -4848,6 +4872,12 @@ describe('Testing listing service', () => { pharmacies: null, publicTransportation: null, schools: null, + busStops: null, + hospitals: null, + playgrounds: null, + recreationalFacilities: null, + seniorCenters: null, + shoppingVenues: null, }, update: { groceryStores: null, @@ -4856,6 +4886,12 @@ describe('Testing listing service', () => { pharmacies: null, publicTransportation: null, schools: null, + busStops: null, + hospitals: null, + playgrounds: null, + recreationalFacilities: null, + seniorCenters: null, + shoppingVenues: null, }, where: { id: undefined, @@ -4999,6 +5035,12 @@ describe('Testing listing service', () => { pharmacies: null, publicTransportation: null, schools: null, + busStops: null, + hospitals: null, + playgrounds: null, + recreationalFacilities: null, + seniorCenters: null, + shoppingVenues: null, }, update: { groceryStores: null, @@ -5007,6 +5049,12 @@ describe('Testing listing service', () => { pharmacies: null, publicTransportation: null, schools: null, + busStops: null, + hospitals: null, + playgrounds: null, + recreationalFacilities: null, + seniorCenters: null, + shoppingVenues: null, }, where: { id: undefined, diff --git a/shared-helpers/src/locales/ar.json b/shared-helpers/src/locales/ar.json index eb3cd82c54..4d3531101d 100644 --- a/shared-helpers/src/locales/ar.json +++ b/shared-helpers/src/locales/ar.json @@ -644,6 +644,12 @@ "listings.amenities.pharmacies": "الصيدليات", "listings.amenities.publicTransportation": "المواصلات العامة", "listings.amenities.schools": "المدارس", + "listings.amenities.shoppingVenues": "أماكن التسوق", + "listings.amenities.hospitals": "المستشفيات", + "listings.amenities.seniorCenters": "مراكز كبار السن", + "listings.amenities.recreationalFacilities": "المرافق الترفيهية", + "listings.amenities.playgrounds": "ملاعب", + "listings.amenities.busStops": "محطات الحافلات", "listings.annualIncome": "%{income} في السنة", "listings.applicationAlreadySubmitted": "لقد تم تقديم هذا الطلب بالفعل.", "listings.applicationDeadline": "تاريخ استحقاق الطلب", diff --git a/shared-helpers/src/locales/bn.json b/shared-helpers/src/locales/bn.json index 911a12b541..3fcf801d42 100644 --- a/shared-helpers/src/locales/bn.json +++ b/shared-helpers/src/locales/bn.json @@ -644,6 +644,12 @@ "listings.amenities.pharmacies": "ফার্মেসী", "listings.amenities.publicTransportation": "গণপরিবহন", "listings.amenities.schools": "স্কুল", + "listings.amenities.shoppingVenues": "শপিং ভেন্যু", + "listings.amenities.hospitals": "হাসপাতাল", + "listings.amenities.seniorCenters": "সিনিয়র সেন্টারসমূহ", + "listings.amenities.recreationalFacilities": "বিনোদন সুবিধা", + "listings.amenities.playgrounds": "খেলার মাঠ", + "listings.amenities.busStops": "বাস স্টপ", "listings.annualIncome": "%{আয়} প্রতি বছর", "listings.applicationAlreadySubmitted": "এই আবেদনটি ইতিমধ্যেই জমা দেওয়া হয়েছে।", "listings.applicationDeadline": "আবেদনের শেষ তারিখ", diff --git a/shared-helpers/src/locales/es.json b/shared-helpers/src/locales/es.json index 69df96dd45..75efb9286c 100644 --- a/shared-helpers/src/locales/es.json +++ b/shared-helpers/src/locales/es.json @@ -644,6 +644,12 @@ "listings.amenities.pharmacies": "Farmacias", "listings.amenities.publicTransportation": "Transporte público", "listings.amenities.schools": "Escuelas", + "listings.amenities.shoppingVenues": "Lugares de compras", + "listings.amenities.hospitals": "Hospitales", + "listings.amenities.seniorCenters": "Centros para personas mayores", + "listings.amenities.recreationalFacilities": "Instalaciones recreativas", + "listings.amenities.playgrounds": "Parques infantiles", + "listings.amenities.busStops": "Paradas de autobús", "listings.annualIncome": "%{income} al año", "listings.applicationAlreadySubmitted": "Ya ha enviado una solicitud para este listado.", "listings.applicationDeadline": "Fecha límite de solicitud", diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index 272d673e3e..3a969528e4 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -644,6 +644,12 @@ "listings.amenities.pharmacies": "Pharmacies", "listings.amenities.publicTransportation": "Public transportation", "listings.amenities.schools": "Schools", + "listings.amenities.shoppingVenues": "Shopping venues", + "listings.amenities.hospitals": "Hospitals", + "listings.amenities.seniorCenters": "Senior centers", + "listings.amenities.recreationalFacilities": "Recreational facilities", + "listings.amenities.playgrounds": "Playgrounds", + "listings.amenities.busStops": "Bus stops", "listings.annualIncome": "%{income} per year", "listings.applicationAlreadySubmitted": "This application has already been submitted.", "listings.applicationDeadline": "Application due date", diff --git a/shared-helpers/src/locales/tl.json b/shared-helpers/src/locales/tl.json index bc816f2528..f5e8c6da22 100644 --- a/shared-helpers/src/locales/tl.json +++ b/shared-helpers/src/locales/tl.json @@ -644,6 +644,12 @@ "listings.amenities.pharmacies": "Mga botika", "listings.amenities.publicTransportation": "Pampublikong transportasyon", "listings.amenities.schools": "Mga paaralan", + "listings.amenities.shoppingVenues": "Mga lugar ng pamimili", + "listings.amenities.hospitals": "Mga ospital", + "listings.amenities.seniorCenters": "Sentro ng mga nakatatanda", + "listings.amenities.recreationalFacilities": "Mga pasilidad ng libangan", + "listings.amenities.playgrounds": "Mga palaruan", + "listings.amenities.busStops": "Mga hintuan ng bus", "listings.annualIncome": "%{income} kada taon", "listings.applicationAlreadySubmitted": "Nagsumite ka na ng aplikasyon para sa listahang ito.", "listings.applicationDeadline": "Takdang petsa ng aplikasyon", diff --git a/shared-helpers/src/locales/vi.json b/shared-helpers/src/locales/vi.json index a9a6ff2bae..4689d4bdba 100644 --- a/shared-helpers/src/locales/vi.json +++ b/shared-helpers/src/locales/vi.json @@ -644,6 +644,12 @@ "listings.amenities.pharmacies": "Nhà thuốc", "listings.amenities.publicTransportation": "Giao thông công cộng", "listings.amenities.schools": "Trường học", + "listings.amenities.shoppingVenues": "Địa điểm mua sắm", + "listings.amenities.hospitals": "Bệnh viện", + "listings.amenities.seniorCenters": "Các trung tâm người cao tuổi", + "listings.amenities.recreationalFacilities": "Cơ sở giải trí", + "listings.amenities.playgrounds": "Sân chơi", + "listings.amenities.busStops": "Trạm xe buýt", "listings.annualIncome": "%{income} mỗi năm", "listings.applicationAlreadySubmitted": "Bạn đã nộp đơn đăng ký cho danh sách này.", "listings.applicationDeadline": "Ngày hết hạn nộp đơn", diff --git a/shared-helpers/src/locales/zh.json b/shared-helpers/src/locales/zh.json index ce096b03ed..58fc3336f7 100644 --- a/shared-helpers/src/locales/zh.json +++ b/shared-helpers/src/locales/zh.json @@ -644,6 +644,12 @@ "listings.amenities.pharmacies": "药店", "listings.amenities.publicTransportation": "公共交通", "listings.amenities.schools": "学校", + "listings.amenities.shoppingVenues": "購物場所", + "listings.amenities.hospitals": "醫院", + "listings.amenities.seniorCenters": "老年中心", + "listings.amenities.recreationalFacilities": "休閒設施", + "listings.amenities.playgrounds": "遊樂場", + "listings.amenities.busStops": "公車站", "listings.annualIncome": "每年 %{income}", "listings.applicationAlreadySubmitted": "您已經提交了此清單的申請。", "listings.applicationDeadline": "申请截止日期", diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index f3654fa018..0cf2324d13 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -3923,6 +3923,24 @@ export interface ListingNeighborhoodAmenities { /** */ healthCareResources?: string + + /** */ + shoppingVenues?: string + + /** */ + hospitals?: string + + /** */ + seniorCenters?: string + + /** */ + recreationalFacilities?: string + + /** */ + playgrounds?: string + + /** */ + busStops?: string } export interface Listing { @@ -7590,6 +7608,12 @@ export enum NeighborhoodAmenitiesEnum { "parksAndCommunityCenters" = "parksAndCommunityCenters", "pharmacies" = "pharmacies", "healthCareResources" = "healthCareResources", + "shoppingVenues" = "shoppingVenues", + "hospitals" = "hospitals", + "seniorCenters" = "seniorCenters", + "recreationalFacilities" = "recreationalFacilities", + "playgrounds" = "playgrounds", + "busStops" = "busStops", } export enum FeatureFlagEnum { diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/NeighborhoodAmenities.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/NeighborhoodAmenities.test.tsx index dc411c0c12..5e23d697ef 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/NeighborhoodAmenities.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/NeighborhoodAmenities.test.tsx @@ -36,6 +36,12 @@ describe("NeighborhoodAmenities", () => { NeighborhoodAmenitiesEnum.parksAndCommunityCenters, NeighborhoodAmenitiesEnum.pharmacies, NeighborhoodAmenitiesEnum.healthCareResources, + NeighborhoodAmenitiesEnum.shoppingVenues, + NeighborhoodAmenitiesEnum.hospitals, + NeighborhoodAmenitiesEnum.seniorCenters, + NeighborhoodAmenitiesEnum.recreationalFacilities, + NeighborhoodAmenitiesEnum.playgrounds, + NeighborhoodAmenitiesEnum.busStops, ], } From f642fc1f3f056b9fa53429c4fa81689c5e856e5a Mon Sep 17 00:00:00 2001 From: Eric McGarry <46828798+mcgarrye@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:11:12 -0500 Subject: [PATCH 03/72] feat: msq crud refactor (#5543) * feat: msq crud refactor * feat: full unit testing * feat: cron setup * feat: align new and existing e2e tests * feat: expanded e2e testing * feat: update permissions and add testing * feat: align script runner * feat: make sure frontend passes status * feat: lint cleanup * feat: add comments to DTO * feat: adjust optOut selections * feat: fix updates for options * feat: add additional test to create func * feat: add missing comments --- api/.env.template | 2 + .../multiselect-question-factory.ts | 67 +- .../multiselect-question.controller.ts | 124 +- .../multiselect-option-create.dto.ts | 6 + .../multiselect-option-update.dto.ts | 22 + .../multiselect-option.dto.ts | 11 +- .../multiselect-question-create.dto.ts | 25 +- .../multiselect-question-update.dto.ts | 28 +- .../multiselect-question.dto.ts | 22 +- .../enums/multiselect-questions/view-enum.ts | 4 + .../modules/multiselect-question.module.ts | 9 +- .../permission-configs/permission_policy.csv | 8 +- .../services/multiselect-question.service.ts | 641 +++++++-- api/src/services/permission.service.ts | 6 + api/src/services/script-runner.service.ts | 64 +- .../multiselect-question.e2e-spec.ts | 1200 +++++++++++++---- .../integration/permission-tests/helpers.ts | 58 +- .../permission-as-admin.e2e-spec.ts | 63 +- ...n-as-juris-admin-correct-juris.e2e-spec.ts | 175 ++- ...ion-as-juris-admin-wrong-juris.e2e-spec.ts | 180 ++- ...ited-juris-admin-correct-juris.e2e-spec.ts | 173 ++- ...imited-juris-admin-wrong-juris.e2e-spec.ts | 160 ++- .../permission-as-no-user.e2e-spec.ts | 63 +- ...ion-as-partner-correct-listing.e2e-spec.ts | 126 +- ...ssion-as-partner-wrong-listing.e2e-spec.ts | 132 +- .../permission-as-public.e2e-spec.ts | 133 +- .../permission-as-support-admin.e2e-spec.ts | 65 +- .../unit/services/listing.service.spec.ts | 2 +- .../multiselect-question.service.spec.ts | 1192 ++++++++++++++-- shared-helpers/src/types/backend-swagger.ts | 244 +++- .../sections/FormMultiselectQuestions.tsx | 6 + .../components/settings/PreferenceDrawer.tsx | 19 +- .../ApplicationMultiselectQuestionStep.tsx | 3 + 33 files changed, 4086 insertions(+), 947 deletions(-) create mode 100644 api/src/dtos/multiselect-questions/multiselect-option-create.dto.ts create mode 100644 api/src/dtos/multiselect-questions/multiselect-option-update.dto.ts create mode 100644 api/src/enums/multiselect-questions/view-enum.ts diff --git a/api/.env.template b/api/.env.template index 992d3cc44e..0a56d2e573 100644 --- a/api/.env.template +++ b/api/.env.template @@ -44,6 +44,8 @@ LOTTERY_PUBLISH_PROCESSING_CRON_STRING=58 23 * * * LOTTERY_PROCESSING_CRON_STRING=0 * * * * # how many days till lottery data expires LOTTERY_DAYS_TILL_EXPIRY=45 +# controls the repetition of the msq retire cron job (should occur after LISTING_PROCESSING_CRON_STRING) +MSQ_RETIRE_CRON_STRING=5 * * * * # how many days until application PII data exists APPLICATION_DAYS_TILL_EXPIRY= # the list of allowed urls that can make requests to the api (strings must be exact matches) diff --git a/api/prisma/seed-helpers/multiselect-question-factory.ts b/api/prisma/seed-helpers/multiselect-question-factory.ts index 4c884f72b8..25cd5930d2 100644 --- a/api/prisma/seed-helpers/multiselect-question-factory.ts +++ b/api/prisma/seed-helpers/multiselect-question-factory.ts @@ -16,35 +16,62 @@ export const multiselectQuestionFactory = ( optOut?: boolean; multiselectQuestion?: Partial; }, + version2 = false, ): Prisma.MultiselectQuestionsCreateInput => { const previousMultiselectQuestion = optionalParams?.multiselectQuestion || {}; + const name = optionalParams?.multiselectQuestion?.name || randomName(); const text = optionalParams?.multiselectQuestion?.text || randomName(); - return { - text: text, - subText: `sub text for ${text}`, - description: `description of ${text}`, - links: [], - options: multiselectOptionFactory(randomInt(1, 3)), - optOutText: optionalParams?.optOut ? "I don't want this preference" : null, - hideFromListing: false, + const baseFields = { applicationSection: optionalParams?.multiselectQuestion?.applicationSection || multiselectAppSectionAsArray[ randomInt(multiselectAppSectionAsArray.length) ], - - // TODO: Temporary until after MSQ refactor - isExclusive: optionalParams?.multiselectQuestion?.isExclusive ?? false, - multiselectOptions: undefined, - name: text, - status: MultiselectQuestionsStatusEnum.draft, - - ...previousMultiselectQuestion, + hideFromListing: false, jurisdiction: { connect: { id: jurisdictionId, }, }, + links: [], + }; + + const v1Fields = { + description: `description of ${text}`, + isExclusive: false, + name: text, + options: multiselectOptionFactory(randomInt(1, 3)), + optOutText: optionalParams?.optOut ? "I don't want this preference" : null, + status: MultiselectQuestionsStatusEnum.draft, + subText: `sub text for ${text}`, + text: text, + }; + const v2Fields = { + description: `description of ${name}`, + isExclusive: optionalParams?.multiselectQuestion?.isExclusive ?? false, + multiselectOptions: { + createMany: { + data: multiselectOptionFactoryV2(randomInt(1, 3)), + }, + }, + name: name, + subText: `sub text for ${name}`, + status: MultiselectQuestionsStatusEnum.draft, + // TODO: Can be removed after MSQ refactor + text: name, + }; + + if (version2) { + return { + ...v2Fields, + ...previousMultiselectQuestion, + ...baseFields, + }; + } + return { + ...v1Fields, + ...previousMultiselectQuestion, + ...baseFields, }; }; @@ -58,3 +85,11 @@ const multiselectOptionFactory = ( collectAddress: index % 2 === 0, })); }; + +const multiselectOptionFactoryV2 = (numberToMake: number) => { + if (!numberToMake) return []; + return [...new Array(numberToMake)].map((_, index) => ({ + name: randomNoun(), + ordinal: index, + })); +}; diff --git a/api/src/controllers/multiselect-question.controller.ts b/api/src/controllers/multiselect-question.controller.ts index cf5835ef75..af20b8ce62 100644 --- a/api/src/controllers/multiselect-question.controller.ts +++ b/api/src/controllers/multiselect-question.controller.ts @@ -1,3 +1,4 @@ +import { Request as ExpressRequest } from 'express'; import { Body, Controller, @@ -6,6 +7,7 @@ import { Param, Post, Put, + Request, Query, UseGuards, UseInterceptors, @@ -18,22 +20,25 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; -import { MultiselectQuestionService } from '../services/multiselect-question.service'; +import { PermissionAction } from '../decorators/permission-action.decorator'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { MultiselectQuestion } from '../dtos/multiselect-questions/multiselect-question.dto'; import { MultiselectQuestionCreate } from '../dtos/multiselect-questions/multiselect-question-create.dto'; +import { MultiselectQuestionFilterParams } from '../dtos/multiselect-questions/multiselect-question-filter-params.dto'; import { MultiselectQuestionUpdate } from '../dtos/multiselect-questions/multiselect-question-update.dto'; import { MultiselectQuestionQueryParams } from '../dtos/multiselect-questions/multiselect-question-query-params.dto'; -import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; import { IdDTO } from '../dtos/shared/id.dto'; -import { SuccessDTO } from '../dtos/shared/success.dto'; -import { MultiselectQuestionFilterParams } from '../dtos/multiselect-questions/multiselect-question-filter-params.dto'; import { PaginationMeta } from '../dtos/shared/pagination.dto'; -import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; -import { OptionalAuthGuard } from '../guards/optional.guard'; -import { PermissionGuard } from '../guards/permission.guard'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { User } from '../dtos/users/user.dto'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { ApiKeyGuard } from '../guards/api-key.guard'; import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; +import { OptionalAuthGuard } from '../guards/optional.guard'; import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor'; -import { ApiKeyGuard } from '../guards/api-key.guard'; +import { MultiselectQuestionService } from '../services/multiselect-question.service'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { mapTo } from '../utilities/mapTo'; @Controller('multiselectQuestions') @ApiTags('multiselectQuestions') @@ -47,7 +52,7 @@ import { ApiKeyGuard } from '../guards/api-key.guard'; IdDTO, ) @PermissionTypeDecorator('multiselectQuestion') -@UseGuards(ApiKeyGuard, OptionalAuthGuard, PermissionGuard) +@UseGuards(ApiKeyGuard, OptionalAuthGuard) export class MultiselectQuestionController { constructor( private readonly multiselectQuestionService: MultiselectQuestionService, @@ -62,42 +67,38 @@ export class MultiselectQuestionController { return await this.multiselectQuestionService.list(queryParams); } - @Get(`:multiselectQuestionId`) - @ApiOperation({ - summary: 'Get multiselect question by id', - operationId: 'retrieve', - }) - @ApiOkResponse({ type: MultiselectQuestion }) - async retrieve( - @Param('multiselectQuestionId') multiselectQuestionId: string, - ): Promise { - return this.multiselectQuestionService.findOne(multiselectQuestionId); - } - @Post() @ApiOperation({ summary: 'Create multiselect question', operationId: 'create', }) @ApiOkResponse({ type: MultiselectQuestion }) - @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + @UseGuards(AdminOrJurisdictionalAdminGuard) async create( @Body() multiselectQuestion: MultiselectQuestionCreate, + @Request() req: ExpressRequest, ): Promise { - return await this.multiselectQuestionService.create(multiselectQuestion); + return await this.multiselectQuestionService.create( + multiselectQuestion, + mapTo(User, req['user']), + ); } - @Put(`:multiselectQuestionId`) + @Put() @ApiOperation({ summary: 'Update multiselect question', operationId: 'update', }) @ApiOkResponse({ type: MultiselectQuestion }) - @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + @UseGuards(AdminOrJurisdictionalAdminGuard) async update( @Body() multiselectQuestion: MultiselectQuestionUpdate, + @Request() req: ExpressRequest, ): Promise { - return await this.multiselectQuestionService.update(multiselectQuestion); + return await this.multiselectQuestionService.update( + multiselectQuestion, + mapTo(User, req['user']), + ); } @Delete() @@ -106,9 +107,74 @@ export class MultiselectQuestionController { operationId: 'delete', }) @ApiOkResponse({ type: SuccessDTO }) - @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + @UseGuards(AdminOrJurisdictionalAdminGuard) + @UseInterceptors(ActivityLogInterceptor) + async delete( + @Body() dto: IdDTO, + @Request() req: ExpressRequest, + ): Promise { + return await this.multiselectQuestionService.delete( + dto.id, + mapTo(User, req['user']), + ); + } + + @Put('reActivate') + @ApiOperation({ + summary: 'Re-activate a multiselect question', + operationId: 'reActivate', + }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(AdminOrJurisdictionalAdminGuard) + async reActivate( + @Body() dto: IdDTO, + @Request() req: ExpressRequest, + ): Promise { + return await this.multiselectQuestionService.reActivate( + dto.id, + mapTo(User, req['user']), + ); + } + + @Put('retire') + @ApiOperation({ + summary: 'Retire a multiselect question', + operationId: 'retire', + }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(AdminOrJurisdictionalAdminGuard) + async retire( + @Body() dto: IdDTO, + @Request() req: ExpressRequest, + ): Promise { + return await this.multiselectQuestionService.retire( + dto.id, + mapTo(User, req['user']), + ); + } + + @Put('retireMultiselectQuestions') + @ApiOperation({ + summary: 'Trigger the retirement of multiselect questions cron job', + operationId: 'retireMultiselectQuestions', + }) + @ApiOkResponse({ type: SuccessDTO }) + @PermissionAction(permissionActions.submit) @UseInterceptors(ActivityLogInterceptor) - async delete(@Body() dto: IdDTO): Promise { - return await this.multiselectQuestionService.delete(dto.id); + @UseGuards(ApiKeyGuard, AdminOrJurisdictionalAdminGuard) + async retireMultiselectQuestions(): Promise { + return await this.multiselectQuestionService.retireMultiselectQuestions(); + } + + @Get(`:multiselectQuestionId`) + @ApiOperation({ + summary: 'Get multiselect question by id', + operationId: 'retrieve', + }) + @ApiOkResponse({ type: MultiselectQuestion }) + async retrieve( + @Param('multiselectQuestionId') multiselectQuestionId: string, + ): Promise { + return this.multiselectQuestionService.findOne(multiselectQuestionId); } } diff --git a/api/src/dtos/multiselect-questions/multiselect-option-create.dto.ts b/api/src/dtos/multiselect-questions/multiselect-option-create.dto.ts new file mode 100644 index 0000000000..7bda9c8d90 --- /dev/null +++ b/api/src/dtos/multiselect-questions/multiselect-option-create.dto.ts @@ -0,0 +1,6 @@ +import { OmitType } from '@nestjs/swagger'; +import { MultiselectOptionUpdate } from './multiselect-option-update.dto'; + +export class MultiselectOptionCreate extends OmitType(MultiselectOptionUpdate, [ + 'id', +]) {} diff --git a/api/src/dtos/multiselect-questions/multiselect-option-update.dto.ts b/api/src/dtos/multiselect-questions/multiselect-option-update.dto.ts new file mode 100644 index 0000000000..8dc6902bba --- /dev/null +++ b/api/src/dtos/multiselect-questions/multiselect-option-update.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { MultiselectOption } from './multiselect-option.dto'; +import { Expose } from 'class-transformer'; +import { IsString, IsUUID } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class MultiselectOptionUpdate extends OmitType(MultiselectOption, [ + // TODO: Temporarily optional until after MSQ refactor + 'id', + 'createdAt', + 'updatedAt', + 'untranslatedName', + 'untranslatedText', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + // TODO: Temporarily optional until after MSQ refactor + // @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; +} diff --git a/api/src/dtos/multiselect-questions/multiselect-option.dto.ts b/api/src/dtos/multiselect-questions/multiselect-option.dto.ts index aa7c425f3f..2a4b89fd6d 100644 --- a/api/src/dtos/multiselect-questions/multiselect-option.dto.ts +++ b/api/src/dtos/multiselect-questions/multiselect-option.dto.ts @@ -8,21 +8,25 @@ import { ValidateNested, } from 'class-validator'; import { MultiselectLink } from './multiselect-link.dto'; +import { AbstractDTO } from '../shared/abstract.dto'; import { IdDTO } from '../shared/id.dto'; import { ValidationMethod } from '../../enums/multiselect-questions/validation-method-enum'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; -export class MultiselectOption { +export class MultiselectOption extends AbstractDTO { + // TODO: This will be sunseted after MSQ refactor @Expose() @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() collectAddress?: boolean; + // TODO: This will be sunseted after MSQ refactor @Expose() @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() collectName?: boolean; + // TODO: This will be sunseted after MSQ refactor @Expose() @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() @@ -106,6 +110,11 @@ export class MultiselectOption { @ApiProperty() text: string; + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + untranslatedName?: string; + @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() diff --git a/api/src/dtos/multiselect-questions/multiselect-question-create.dto.ts b/api/src/dtos/multiselect-questions/multiselect-question-create.dto.ts index 32b8c0ebe1..c3048100f6 100644 --- a/api/src/dtos/multiselect-questions/multiselect-question-create.dto.ts +++ b/api/src/dtos/multiselect-questions/multiselect-question-create.dto.ts @@ -1,7 +1,26 @@ -import { OmitType } from '@nestjs/swagger'; +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { ArrayMaxSize, ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { MultiselectOptionCreate } from './multiselect-option-create.dto'; import { MultiselectQuestionUpdate } from './multiselect-question-update.dto'; export class MultiselectQuestionCreate extends OmitType( MultiselectQuestionUpdate, - ['id'], -) {} + ['id', 'multiselectOptions', 'options'], +) { + // TODO: Temporarily optional until after MSQ refactor + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => MultiselectOptionCreate) + @ApiPropertyOptional({ type: MultiselectOptionCreate, isArray: true }) + multiselectOptions?: MultiselectOptionCreate[]; + + // TODO: This will be sunseted after MSQ refactor + @Expose() + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => MultiselectOptionCreate) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiPropertyOptional({ type: MultiselectOptionCreate, isArray: true }) + options?: MultiselectOptionCreate[]; +} diff --git a/api/src/dtos/multiselect-questions/multiselect-question-update.dto.ts b/api/src/dtos/multiselect-questions/multiselect-question-update.dto.ts index 47d55466e1..f0e9742d12 100644 --- a/api/src/dtos/multiselect-questions/multiselect-question-update.dto.ts +++ b/api/src/dtos/multiselect-questions/multiselect-question-update.dto.ts @@ -1,10 +1,30 @@ -import { OmitType } from '@nestjs/swagger'; +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { ArrayMaxSize, ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { MultiselectOptionUpdate } from './multiselect-option-update.dto'; import { MultiselectQuestion } from './multiselect-question.dto'; export class MultiselectQuestionUpdate extends OmitType(MultiselectQuestion, [ 'createdAt', 'updatedAt', - 'status', + 'multiselectOptions', + 'options', + 'untranslatedName', 'untranslatedText', - 'untranslatedText', -]) {} +]) { + // TODO: Temporarily optional until after MSQ refactor + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => MultiselectOptionUpdate) + @ApiPropertyOptional({ type: MultiselectOptionUpdate, isArray: true }) + multiselectOptions?: MultiselectOptionUpdate[]; + + // TODO: This will be sunseted after MSQ refactor + @Expose() + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => MultiselectOptionUpdate) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiPropertyOptional({ type: MultiselectOptionUpdate, isArray: true }) + options?: MultiselectOptionUpdate[]; +} diff --git a/api/src/dtos/multiselect-questions/multiselect-question.dto.ts b/api/src/dtos/multiselect-questions/multiselect-question.dto.ts index b1d6e44230..3953a4d45b 100644 --- a/api/src/dtos/multiselect-questions/multiselect-question.dto.ts +++ b/api/src/dtos/multiselect-questions/multiselect-question.dto.ts @@ -36,7 +36,7 @@ class MultiselectQuestion extends AbstractDTO { description?: string; // TODO: Temporarily optional until after MSQ refactor - // @Expose() + @Expose() @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() isExclusive?: boolean; @@ -47,13 +47,14 @@ class MultiselectQuestion extends AbstractDTO { hideFromListing?: boolean; // TODO: Temporarily optional until after MSQ refactor - // @Expose() + @Expose() // @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @Type(() => IdDTO) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional({ type: IdDTO }) jurisdiction?: IdDTO; + // TODO: This will be sunseted after MSQ refactor but still required at the moment @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @Type(() => IdDTO) @@ -68,7 +69,7 @@ class MultiselectQuestion extends AbstractDTO { links?: MultiselectLink[]; // TODO: Temporarily optional until after MSQ refactor - // @Expose() + @Expose() // @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @Type(() => MultiselectOption) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @@ -76,12 +77,13 @@ class MultiselectQuestion extends AbstractDTO { multiselectOptions?: MultiselectOption[]; // TODO: Temporarily optional until after MSQ refactor - // @Expose() + @Expose() // @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() name?: string; + // TODO: This will be sunseted after MSQ refactor @Expose() @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) @Type(() => MultiselectOption) @@ -89,13 +91,13 @@ class MultiselectQuestion extends AbstractDTO { @ApiPropertyOptional({ type: MultiselectOption, isArray: true }) options?: MultiselectOption[]; + // TODO: This will be sunseted after MSQ refactor @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() optOutText?: string; - // TODO: Temporarily optional until after MSQ refactor - // @Expose() + @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @IsEnum(MultiselectQuestionsStatusEnum, { groups: [ValidationsGroupsEnum.default], @@ -103,6 +105,7 @@ class MultiselectQuestion extends AbstractDTO { @ApiProperty({ enum: MultiselectQuestionsStatusEnum, enumName: 'MultiselectQuestionsStatusEnum', + example: 'draft', }) status: MultiselectQuestionsStatusEnum; @@ -111,12 +114,19 @@ class MultiselectQuestion extends AbstractDTO { @ApiPropertyOptional() subText?: string; + // TODO: This will be sunseted after MSQ refactor but still required at the moment @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiProperty() text: string; + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + untranslatedName?: string; + + // TODO: This will be sunseted after MSQ refactor @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() diff --git a/api/src/enums/multiselect-questions/view-enum.ts b/api/src/enums/multiselect-questions/view-enum.ts new file mode 100644 index 0000000000..340245d970 --- /dev/null +++ b/api/src/enums/multiselect-questions/view-enum.ts @@ -0,0 +1,4 @@ +export enum MultiselectQuestionViews { + base = 'base', + fundamentals = 'fundamentals', +} diff --git a/api/src/modules/multiselect-question.module.ts b/api/src/modules/multiselect-question.module.ts index d4c2b0d713..4bb6f9b4e0 100644 --- a/api/src/modules/multiselect-question.module.ts +++ b/api/src/modules/multiselect-question.module.ts @@ -1,13 +1,14 @@ -import { Module } from '@nestjs/common'; +import { Logger, Module } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { PermissionModule } from './permission.module'; +import { PrismaModule } from './prisma.module'; import { MultiselectQuestionController } from '../controllers/multiselect-question.controller'; import { MultiselectQuestionService } from '../services/multiselect-question.service'; -import { PrismaModule } from './prisma.module'; -import { PermissionModule } from './permission.module'; @Module({ imports: [PrismaModule, PermissionModule], controllers: [MultiselectQuestionController], - providers: [MultiselectQuestionService], + providers: [Logger, MultiselectQuestionService, SchedulerRegistry], exports: [MultiselectQuestionService], }) export class MultiselectQuestionModule {} diff --git a/api/src/permission-configs/permission_policy.csv b/api/src/permission-configs/permission_policy.csv index e34307b0a9..c169310648 100644 --- a/api/src/permission-configs/permission_policy.csv +++ b/api/src/permission-configs/permission_policy.csv @@ -17,10 +17,10 @@ p, limitedJurisdictionAdmin, asset, true, .* p, partner, asset, true, .* p, admin, multiselectQuestion, true, .* -p, supportAdmin, multiselectQuestion, true, .* -p, jurisdictionAdmin, multiselectQuestion, true, .* -p, limitedJurisdictionAdmin, multiselectQuestion, true, .* -p, partner, multiselectQuestion, true, .* +p, supportAdmin, multiselectQuestion, true, read +p, jurisdictionAdmin, multiselectQuestion, true, read +p, limitedJurisdictionAdmin, multiselectQuestion, true, read +p, partner, multiselectQuestion, true, read p, anonymous, multiselectQuestion, true, read p, admin, applicationMethod, true, .* diff --git a/api/src/services/multiselect-question.service.ts b/api/src/services/multiselect-question.service.ts index aa42ac4374..868f4f1ce4 100644 --- a/api/src/services/multiselect-question.service.ts +++ b/api/src/services/multiselect-question.service.ts @@ -1,26 +1,74 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { + ListingsStatusEnum, + MultiselectQuestionsStatusEnum, + Prisma, +} from '@prisma/client'; +import { PermissionService } from './permission.service'; import { PrismaService } from './prisma.service'; +import { Jurisdiction } from '../dtos/jurisdictions/jurisdiction.dto'; import { MultiselectQuestion } from '../dtos/multiselect-questions/multiselect-question.dto'; -import { MultiselectQuestionUpdate } from '../dtos/multiselect-questions/multiselect-question-update.dto'; import { MultiselectQuestionCreate } from '../dtos/multiselect-questions/multiselect-question-create.dto'; -import { mapTo } from '../utilities/mapTo'; +import { MultiselectQuestionUpdate } from '../dtos/multiselect-questions/multiselect-question-update.dto'; +import { MultiselectQuestionQueryParams } from '../dtos/multiselect-questions/multiselect-question-query-params.dto'; import { SuccessDTO } from '../dtos/shared/success.dto'; -import { MultiselectQuestionsStatusEnum, Prisma } from '@prisma/client'; -import { buildFilter } from '../utilities/build-filter'; +import { User } from '../dtos/users/user.dto'; +import { FeatureFlagEnum } from '../enums/feature-flags/feature-flags-enum'; import { MultiselectQuestionFilterKeys } from '../enums/multiselect-questions/filter-key-enum'; -import { MultiselectQuestionQueryParams } from '../dtos/multiselect-questions/multiselect-question-query-params.dto'; +import { MultiselectQuestionViews } from '../enums/multiselect-questions/view-enum'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { buildFilter } from '../utilities/build-filter'; +import { startCronJob } from '../utilities/cron-job-starter'; +import { doJurisdictionHaveFeatureFlagSet } from '../utilities/feature-flag-utilities'; +import { mapTo } from '../utilities/mapTo'; + +export const includeViews: Partial< + Record +> = { + fundamentals: { + jurisdiction: true, + multiselectOptions: true, + }, +}; -const view: Prisma.MultiselectQuestionsInclude = { - jurisdiction: true, +includeViews.base = { + ...includeViews.fundamentals, + listings: true, }; +const MSQ_RETIRE_CRON_JOB_NAME = 'MSQ_RETIRE_CRON_JOB'; + /* this is the service for multiselect questions - it handles all the backend's business logic for reading/writing/deleting multiselect questione data + it handles all the backend's business logic for reading/writing/deleting multiselect question data */ @Injectable() export class MultiselectQuestionService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + @Inject(Logger) + private logger = new Logger(MultiselectQuestionService.name), + private permissionService: PermissionService, + private schedulerRegistry: SchedulerRegistry, + ) {} + + onModuleInit() { + startCronJob( + this.prisma, + MSQ_RETIRE_CRON_JOB_NAME, + process.env.MSQ_RETIRE_CRON_STRING, + this.retireMultiselectQuestions.bind(this), + this.logger, + this.schedulerRegistry, + ); + } /* this will get a set of multiselect questions given the params passed in @@ -30,15 +78,16 @@ export class MultiselectQuestionService { ): Promise { let rawMultiselectQuestions = await this.prisma.multiselectQuestions.findMany({ - include: view, + include: includeViews.fundamentals, where: this.buildWhere(params), }); - // TODO: Temporary until front end accepts MSQ refactor - rawMultiselectQuestions = rawMultiselectQuestions.map((msq) => ({ - ...msq, - jurisdictions: [msq.jurisdiction], - })); + rawMultiselectQuestions = rawMultiselectQuestions.map((msq) => { + return { + ...msq, + jurisdictions: [msq.jurisdiction], + }; + }); return mapTo(MultiselectQuestion, rawMultiselectQuestions); } @@ -93,27 +142,29 @@ export class MultiselectQuestionService { /* this will return 1 multiselect question or error */ - async findOne(multiSelectQuestionId: string): Promise { + async findOne( + multiselectQuestionId: string, + view: MultiselectQuestionViews = MultiselectQuestionViews.fundamentals, + ): Promise { const rawMultiselectQuestion = - await this.prisma.multiselectQuestions.findFirst({ + await this.prisma.multiselectQuestions.findUnique({ + include: includeViews[view], where: { - id: { - equals: multiSelectQuestionId, - }, + id: multiselectQuestionId, }, - include: view, }); if (!rawMultiselectQuestion) { throw new NotFoundException( - `multiselectQuestionId ${multiSelectQuestionId} was requested but not found`, + `multiselectQuestionId ${multiselectQuestionId} was requested but not found`, ); } - // TODO: Temporary until front end accepts MSQ refactor + // TODO: Can be removed after MSQ refactor rawMultiselectQuestion['jurisdictions'] = [ rawMultiselectQuestion.jurisdiction, ]; + return mapTo(MultiselectQuestion, rawMultiselectQuestion); } @@ -122,35 +173,105 @@ export class MultiselectQuestionService { */ async create( incomingData: MultiselectQuestionCreate, + requestingUser: User, ): Promise { - const { jurisdictions, links, options, ...createData } = incomingData; + const { + isExclusive, + jurisdiction, + jurisdictions, + links, + multiselectOptions, + name, + options, + status, + ...createData + } = incomingData; + + const rawJurisdiction = await this.prisma.jurisdictions.findFirstOrThrow({ + select: { + featureFlags: true, + id: true, + }, + where: { + id: jurisdiction + ? jurisdiction.id + : jurisdictions?.at(0) + ? jurisdictions?.at(0)?.id + : undefined, + }, + }); + + await this.permissionService.canOrThrow( + requestingUser, + 'multiselectQuestion', + permissionActions.create, + { + jurisdictionId: rawJurisdiction.id, + }, + ); + + const enableV2MSQ = doJurisdictionHaveFeatureFlagSet( + rawJurisdiction as Jurisdiction, + FeatureFlagEnum.enableV2MSQ, + ); + + if ( + status && + !( + status === MultiselectQuestionsStatusEnum.draft || + status === MultiselectQuestionsStatusEnum.visible + ) + ) { + throw new BadRequestException( + "status must be 'draft' or 'visible' on create", + ); + } const rawMultiselectQuestion = await this.prisma.multiselectQuestions.create({ data: { ...createData, jurisdiction: { - connect: jurisdictions?.at(0)?.id - ? { id: jurisdictions?.at(0)?.id } - : undefined, + connect: { id: rawJurisdiction.id }, }, links: links ? (links as unknown as Prisma.InputJsonArray) : undefined, + + // TODO: Can be removed after MSQ refactor options: options ? (options as unknown as Prisma.InputJsonArray) : undefined, - status: MultiselectQuestionsStatusEnum.draft, - // TODO: Temporary until after MSQ refactor - isExclusive: false, - multiselectOptions: undefined, - name: createData.text, + // TODO: Use of the feature flag is temporary until after MSQ refactor + isExclusive: enableV2MSQ ? isExclusive : false, + name: enableV2MSQ ? name : createData.text, + status: enableV2MSQ ? status : MultiselectQuestionsStatusEnum.draft, + + multiselectOptions: enableV2MSQ + ? { + createMany: { + data: multiselectOptions?.map((option) => { + // TODO: Can be removed after MSQ refactor + delete option['collectAddress']; + delete option['collectName']; + delete option['collectRelationship']; + delete option['exclusive']; + delete option['text']; + return { + ...option, + links: option.links as unknown as Prisma.InputJsonArray, + name: option.name, + }; + }), + }, + } + : undefined, }, - include: view, + include: includeViews.fundamentals, }); - // TODO: Temporary until front end accepts MSQ refactor + // TODO: Can be removed after MSQ refactor rawMultiselectQuestion['jurisdictions'] = [ rawMultiselectQuestion.jurisdiction, ]; @@ -163,54 +284,164 @@ export class MultiselectQuestionService { */ async update( incomingData: MultiselectQuestionUpdate, + requestingUser: User, ): Promise { - const { id, jurisdictions, links, options, ...updateData } = incomingData; + const { + id, + isExclusive, + jurisdiction, + jurisdictions, + links, + multiselectOptions, + name, + options, + status, + ...updateData + } = incomingData; - await this.findOrThrow(id); + const currentMultiselectQuestion = await this.findOne(id); - const rawMultiselectQuestion = - await this.prisma.multiselectQuestions.update({ + const rawJurisdiction = await this.prisma.jurisdictions.findFirstOrThrow({ + select: { + featureFlags: true, + id: true, + }, + where: { + id: jurisdiction + ? jurisdiction.id + : jurisdictions?.at(0) + ? jurisdictions?.at(0)?.id + : undefined, + }, + }); + + await this.permissionService.canOrThrow( + requestingUser, + 'multiselectQuestion', + permissionActions.update, + { + id: id, + jurisdictionId: rawJurisdiction.id, + }, + ); + + const enableV2MSQ = doJurisdictionHaveFeatureFlagSet( + rawJurisdiction as Jurisdiction, + FeatureFlagEnum.enableV2MSQ, + ); + + if (enableV2MSQ) { + this.validateStatusStateTransition( + currentMultiselectQuestion.status, + status, + ); + } + + // Wrap the deletion and update in one transaction so that multiselectOptions aren't lost if update fails + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const transactions = await this.prisma.$transaction([ + // delete the multiselect options + this.prisma.multiselectOptions.deleteMany({ + where: { + multiselectQuestionId: id, + }, + }), + // update the multiselect question + this.prisma.multiselectQuestions.update({ data: { ...updateData, id: undefined, jurisdiction: { - connect: jurisdictions?.at(0)?.id - ? { id: jurisdictions?.at(0)?.id } - : undefined, + connect: { id: rawJurisdiction.id }, }, links: links ? (links as unknown as Prisma.InputJsonArray) : undefined, + // TODO: Can be removed after MSQ refactor options: options ? (options as unknown as Prisma.InputJsonArray) : undefined, - // TODO: Temporary until after MSQ refactor - isExclusive: false, - multiselectOptions: undefined, - name: updateData.text, + // TODO: Use of the feature flag is temporary until after MSQ refactor + isExclusive: enableV2MSQ ? isExclusive : false, + name: enableV2MSQ ? name : updateData.text, + status: enableV2MSQ ? status : MultiselectQuestionsStatusEnum.draft, + + multiselectOptions: enableV2MSQ + ? { + createMany: { + data: multiselectOptions?.map((option) => { + delete option['id']; + // TODO: The following 5 deletes can be removed after MSQ refactor + delete option['collectAddress']; + delete option['collectName']; + delete option['collectRelationship']; + delete option['exclusive']; + delete option['text']; + return { + ...option, + links: option.links as unknown as Prisma.InputJsonArray, + name: option.name, + }; + }), + }, + } + : undefined, }, where: { id: id, }, - include: view, - }); + include: includeViews.fundamentals, + }), + ]); + const rawMultiselectQuestion = transactions[ + transactions.length - 1 + ] as unknown as MultiselectQuestion; - // TODO: Temporary until front end accepts MSQ refactor + // TODO: Can be removed after MSQ refactor rawMultiselectQuestion['jurisdictions'] = [ - rawMultiselectQuestion?.jurisdiction, + rawMultiselectQuestion.jurisdiction, ]; + return mapTo(MultiselectQuestion, rawMultiselectQuestion); } /* this will delete a multiselect question */ - async delete(multiSelectQuestionId: string): Promise { - await this.findOrThrow(multiSelectQuestionId); + async delete( + multiselectQuestionId: string, + requestingUser: User, + ): Promise { + const currentMultiselectQuestion = await this.findOne( + multiselectQuestionId, + ); + + const enableV2MSQ = doJurisdictionHaveFeatureFlagSet( + currentMultiselectQuestion.jurisdiction as Jurisdiction, + FeatureFlagEnum.enableV2MSQ, + ); + + await this.permissionService.canOrThrow( + requestingUser, + 'multiselectQuestion', + permissionActions.delete, + { + id: multiselectQuestionId, + jurisdictionId: currentMultiselectQuestion.jurisdiction.id, + }, + ); + + if (enableV2MSQ) { + this.validateStatusStateTransition( + currentMultiselectQuestion.status, + currentMultiselectQuestion.status, + ); + } + await this.prisma.multiselectQuestions.delete({ where: { - id: multiSelectQuestionId, + id: multiselectQuestionId, }, }); return { @@ -218,16 +449,197 @@ export class MultiselectQuestionService { } as SuccessDTO; } - /* - this will either find a record or throw a customized error + async findByListingId(listingId: string): Promise { + let rawMultiselectQuestions = + await this.prisma.multiselectQuestions.findMany({ + include: includeViews.base, + where: { + listings: { + some: { + listingId, + }, + }, + }, + }); + + // TODO: Temporary until front end accepts MSQ refactor + rawMultiselectQuestions = rawMultiselectQuestions.map((msq) => ({ + ...msq, + jurisdictions: [{ id: msq.jurisdictionId }], + })); + return mapTo(MultiselectQuestion, rawMultiselectQuestions); + } + + /** + validates that the attempted status state transition is allowed in the state machine, + if not it throws a custom error */ - async findOrThrow( + validateStatusStateTransition( + currentState: MultiselectQuestionsStatusEnum, + nextState: MultiselectQuestionsStatusEnum, + ) { + if (currentState === nextState) { + if ( + nextState === MultiselectQuestionsStatusEnum.active || + nextState === MultiselectQuestionsStatusEnum.toRetire || + nextState === MultiselectQuestionsStatusEnum.retired + ) { + throw new BadRequestException( + `A multiselect question of status '${nextState}' cannot be edited or deleted`, + ); + } + return; + } + + switch (currentState) { + case MultiselectQuestionsStatusEnum.draft: + if (nextState !== MultiselectQuestionsStatusEnum.visible) { + throw new BadRequestException( + "status 'draft' can only change to 'visible'", + ); + } + break; + case MultiselectQuestionsStatusEnum.visible: + if ( + nextState !== MultiselectQuestionsStatusEnum.draft && + nextState !== MultiselectQuestionsStatusEnum.active + ) { + throw new BadRequestException( + "status 'visible' can only change to 'draft' or 'active'", + ); + } + break; + case MultiselectQuestionsStatusEnum.active: + if ( + nextState !== MultiselectQuestionsStatusEnum.toRetire && + nextState !== MultiselectQuestionsStatusEnum.retired + ) { + throw new BadRequestException( + "status 'active' can only change to 'toRetire' or 'retired'", + ); + } + break; + + case MultiselectQuestionsStatusEnum.toRetire: + if ( + nextState !== MultiselectQuestionsStatusEnum.retired && + nextState !== MultiselectQuestionsStatusEnum.active + ) { + throw new BadRequestException( + "status 'toRetire' can only change to 'retired'", + ); + } + break; + + case MultiselectQuestionsStatusEnum.retired: + throw new BadRequestException("status 'retired' cannot be changed"); + + default: + throw new BadRequestException( + `current status is not of type MultiselectQuestionsStatusEnum: ${currentState}`, + ); + } + } + + /** + moves a multiselect question to a new status state + */ + async statusStateTransition( + multiselectQuestion: MultiselectQuestion, + status: MultiselectQuestionsStatusEnum, + ) { + this.validateStatusStateTransition(multiselectQuestion.status, status); + + await this.prisma.multiselectQuestions.update({ + data: { + status: status, + }, + where: { + id: multiselectQuestion.id, + }, + }); + } + + /** + actives any visible multiselect questions + */ + async activateMany( + multiselectQuestions: MultiselectQuestion[], + ): Promise { + if ( + multiselectQuestions.some( + (multiselectQuestion) => + multiselectQuestion.status === MultiselectQuestionsStatusEnum.draft || + multiselectQuestion.status === MultiselectQuestionsStatusEnum.retired, + ) + ) { + throw new BadRequestException( + 'only multiselect questions in visible, active or toRetire status can be associated with a listing being published', + ); + } + // What if one fails? + for (const multiselectQuestion of multiselectQuestions) { + if ( + multiselectQuestion.status === MultiselectQuestionsStatusEnum.visible + ) { + await this.statusStateTransition( + multiselectQuestion, + MultiselectQuestionsStatusEnum.active, + ); + } + } + return { + success: true, + } as SuccessDTO; + } + + async reActivate( multiselectQuestionId: string, - ): Promise { + requestingUser: User, + ): Promise { + const multiselectQuestion = await this.findOne(multiselectQuestionId); + + await this.permissionService.canOrThrow( + requestingUser, + 'multiselectQuestion', + permissionActions.update, + { + id: multiselectQuestionId, + jurisdictionId: multiselectQuestion.jurisdiction.id, + }, + ); + + await this.statusStateTransition( + multiselectQuestion, + MultiselectQuestionsStatusEnum.active, + ); + + return { + success: true, + } as SuccessDTO; + } + + /** + attempts to move a multiselect question to retired status, + if it is still associated with open listings it is moved to toRetire + */ + async retire( + multiselectQuestionId: string, + requestingUser: User, + ): Promise { const rawMultiselectQuestion = - await this.prisma.multiselectQuestions.findFirst({ + await this.prisma.multiselectQuestions.findUnique({ include: { jurisdiction: true, + listings: { + include: { + listings: { + select: { + status: true, + }, + }, + }, + }, }, where: { id: multiselectQuestionId, @@ -240,33 +652,102 @@ export class MultiselectQuestionService { ); } - // TODO: Temporary until front end accepts MSQ refactor - rawMultiselectQuestion['jurisdictions'] = [ - rawMultiselectQuestion.jurisdiction, - ]; - return mapTo(MultiselectQuestion, rawMultiselectQuestion); + await this.permissionService.canOrThrow( + requestingUser, + 'multiselectQuestion', + permissionActions.update, + { + id: multiselectQuestionId, + jurisdictionId: rawMultiselectQuestion.jurisdiction.id, + }, + ); + + const multiselectQuestion = mapTo( + MultiselectQuestion, + rawMultiselectQuestion, + ); + + if ( + rawMultiselectQuestion.listings.every( + ({ listings }) => listings.status === ListingsStatusEnum.closed, + ) + ) { + await this.statusStateTransition( + multiselectQuestion, + MultiselectQuestionsStatusEnum.retired, + ); + } else { + await this.statusStateTransition( + multiselectQuestion, + MultiselectQuestionsStatusEnum.toRetire, + ); + } + + return { + success: true, + } as SuccessDTO; } - async findByListingId(listingId: string): Promise { - let rawMultiselectQuestions = - await this.prisma.multiselectQuestions.findMany({ - include: { - listings: true, - }, - where: { - listings: { - some: { - listingId, + /** + runs the job to auto retire multiselect questions that are waiting to be retired + */ + async retireMultiselectQuestions(): Promise { + this.logger.warn('retireMultiselectQuestionsCron job running'); + await this.markCronJobAsStarted('MSQ_RETIRE_CRON_JOB'); + + const res = await this.prisma.multiselectQuestions.updateMany({ + data: { + status: MultiselectQuestionsStatusEnum.retired, + }, + where: { + listings: { + every: { + listings: { + status: ListingsStatusEnum.closed, }, }, }, - }); + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }); - // TODO: Temporary until front end accepts MSQ refactor - rawMultiselectQuestions = rawMultiselectQuestions.map((msq) => ({ - ...msq, - jurisdictions: [{ id: msq.jurisdictionId }], - })); - return mapTo(MultiselectQuestion, rawMultiselectQuestions); + this.logger.warn( + `Changed the status of ${res?.count} multiselect questions`, + ); + + return { + success: true, + }; + } + + /** + marks the db record for this cronjob as begun or creates a cronjob that + is marked as begun if one does not already exist + */ + async markCronJobAsStarted(cronJobName: string): Promise { + const job = await this.prisma.cronJob.findFirst({ + where: { + name: cronJobName, + }, + }); + if (job) { + // if a job exists then we update db entry + await this.prisma.cronJob.update({ + data: { + lastRunDate: new Date(), + }, + where: { + id: job.id, + }, + }); + } else { + // if no job we create a new entry + await this.prisma.cronJob.create({ + data: { + lastRunDate: new Date(), + name: cronJobName, + }, + }); + } } } diff --git a/api/src/services/permission.service.ts b/api/src/services/permission.service.ts index 5f9aa6e8df..f91204f974 100644 --- a/api/src/services/permission.service.ts +++ b/api/src/services/permission.service.ts @@ -98,6 +98,12 @@ export class PermissionService { `r.obj.jurisdictionId == '${adminInJurisdiction.id}'`, `(${permissionActions.read}|${permissionActions.create}|${permissionActions.update}|${permissionActions.delete})`, ); + await enforcer.addPermissionForUser( + user.id, + 'multiselectQuestion', + `r.obj.jurisdictionId == '${adminInJurisdiction.id}'`, + `(${permissionActions.read}|${permissionActions.create}|${permissionActions.update}|${permissionActions.delete})`, + ); await enforcer.addPermissionForUser( user.id, 'user', diff --git a/api/src/services/script-runner.service.ts b/api/src/services/script-runner.service.ts index f469487e30..61a46d2c2a 100644 --- a/api/src/services/script-runner.service.ts +++ b/api/src/services/script-runner.service.ts @@ -573,20 +573,24 @@ export class ScriptRunnerService { jurisInfo?.length ? jurisInfo[0].name : '', translations, ); - await this.multiselectQuestionService.create({ - text: pref.title, - subText: pref.subtitle, - description: pref.description, - links: pref.links ?? null, - hideFromListing: this.resolveHideFromListings(pref), - optOutText: optOutText ?? null, - options: options, - applicationSection: - MultiselectQuestionsApplicationSectionEnum.preferences, - jurisdictions: jurisInfo.map((juris) => { - return { id: juris.id }; - }), - }); + await this.multiselectQuestionService.create( + { + text: pref.title, + subText: pref.subtitle, + description: pref.description, + links: pref.links ?? null, + hideFromListing: this.resolveHideFromListings(pref), + optOutText: optOutText ?? null, + options: options, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + jurisdictions: jurisInfo.map((juris) => { + return { id: juris.id }; + }), + status: 'draft', + }, + requestingUser, + ); } // begin migration from programs @@ -618,20 +622,24 @@ export class ScriptRunnerService { `); const res: MultiselectQuestion = - await this.multiselectQuestionService.create({ - text: prog.title, - subText: prog.subtitle, - description: prog.description, - links: null, - hideFromListing: this.resolveHideFromListings(prog), - optOutText: null, - options: null, - applicationSection: - MultiselectQuestionsApplicationSectionEnum.programs, - jurisdictions: jurisInfo.map((juris) => { - return { id: juris.id }; - }), - }); + await this.multiselectQuestionService.create( + { + text: prog.title, + subText: prog.subtitle, + description: prog.description, + links: null, + hideFromListing: this.resolveHideFromListings(prog), + optOutText: null, + options: null, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + jurisdictions: jurisInfo.map((juris) => { + return { id: juris.id }; + }), + status: 'draft', + }, + requestingUser, + ); const listingsInfo: { ordinal; listing_id }[] = await this.prisma .$queryRawUnsafe(` diff --git a/api/test/integration/multiselect-question.e2e-spec.ts b/api/test/integration/multiselect-question.e2e-spec.ts index de20dc2338..2b37fd3f4b 100644 --- a/api/test/integration/multiselect-question.e2e-spec.ts +++ b/api/test/integration/multiselect-question.e2e-spec.ts @@ -1,27 +1,32 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import { MultiselectQuestionsApplicationSectionEnum } from '@prisma/client'; +import cookieParser from 'cookie-parser'; import { randomUUID } from 'crypto'; import { stringify } from 'qs'; import request from 'supertest'; -import cookieParser from 'cookie-parser'; -import { AppModule } from '../../src/modules/app.module'; -import { PrismaService } from '../../src/services/prisma.service'; -import { multiselectQuestionFactory } from '../../prisma/seed-helpers/multiselect-question-factory'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + ListingsStatusEnum, + MultiselectQuestionsApplicationSectionEnum, + MultiselectQuestionsStatusEnum, +} from '@prisma/client'; +import { createAllFeatureFlags } from '../../prisma/seed-helpers/feature-flag-factory'; import { jurisdictionFactory } from '../../prisma/seed-helpers/jurisdiction-factory'; +import { listingFactory } from '../../prisma/seed-helpers/listing-factory'; +import { multiselectQuestionFactory } from '../../prisma/seed-helpers/multiselect-question-factory'; +import { userFactory } from '../../prisma/seed-helpers/user-factory'; +import { Login } from '../../src/dtos/auth/login.dto'; import { MultiselectQuestionCreate } from '../../src/dtos/multiselect-questions/multiselect-question-create.dto'; -import { MultiselectQuestionUpdate } from '../../src/dtos/multiselect-questions/multiselect-question-update.dto'; -import { IdDTO } from '../../src/dtos/shared/id.dto'; import { MultiselectQuestionQueryParams } from '../../src/dtos/multiselect-questions/multiselect-question-query-params.dto'; +import { MultiselectQuestionUpdate } from '../../src/dtos/multiselect-questions/multiselect-question-update.dto'; import { Compare } from '../../src/dtos/shared/base-filter.dto'; -import { userFactory } from '../../prisma/seed-helpers/user-factory'; -import { Login } from '../../src/dtos/auth/login.dto'; +import { IdDTO } from '../../src/dtos/shared/id.dto'; +import { FeatureFlagEnum } from '../../src/enums/feature-flags/feature-flags-enum'; +import { AppModule } from '../../src/modules/app.module'; +import { PrismaService } from '../../src/services/prisma.service'; describe('MultiselectQuestion Controller Tests', () => { let app: INestApplication; let prisma: PrismaService; - let jurisdictionId: string; - let cookies = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -32,28 +37,7 @@ describe('MultiselectQuestion Controller Tests', () => { prisma = moduleFixture.get(PrismaService); app.use(cookieParser()); await app.init(); - const jurisdiction = await prisma.jurisdictions.create({ - data: jurisdictionFactory(), - }); - jurisdictionId = jurisdiction.id; - - const storedUser = await prisma.userAccounts.create({ - data: await userFactory({ - roles: { isAdmin: true }, - mfaEnabled: false, - confirmedAt: new Date(), - }), - }); - const resLogIn = await request(app.getHttpServer()) - .post('/auth/login') - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - email: storedUser.email, - password: 'Abcdef12345!', - } as Login) - .expect(201); - - cookies = resLogIn.headers['set-cookie']; + await createAllFeatureFlags(prisma); }); afterAll(async () => { @@ -61,294 +45,948 @@ describe('MultiselectQuestion Controller Tests', () => { await app.close(); }); - it('should get multiselect questions from list endpoint when no params are sent', async () => { - const jurisdictionB = await prisma.jurisdictions.create({ - data: jurisdictionFactory(), - }); + // TODO: Can be removed after MSQ refactor + describe('current msq implementation', () => { + let jurisdictionId: string; + let cookies = ''; + beforeAll(async () => { + const jurisdiction = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + jurisdictionId = jurisdiction.id; - const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionB.id), - }); - const multiselectQuestionB = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionB.id), + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + mfaEnabled: false, + confirmedAt: new Date(), + }), + }); + const resLogIn = await request(app.getHttpServer()) + .post('/auth/login') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + email: storedUser.email, + password: 'Abcdef12345!', + } as Login) + .expect(201); + + cookies = resLogIn.headers['set-cookie']; }); - const res = await request(app.getHttpServer()) - .get(`/multiselectQuestions?`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) - .expect(200); + describe('list', () => { + it('should get multiselect questions from list endpoint when no params are sent', async () => { + const jurisdictionB = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); - expect(res.body.length).toBeGreaterThanOrEqual(2); - const multiselectQuestions = res.body.map((value) => value.text); - expect(multiselectQuestions).toContain(multiselectQuestionA.text); - expect(multiselectQuestions).toContain(multiselectQuestionB.text); - }); + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionB.id), + }); + const multiselectQuestionB = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionB.id), + }); - it('should get multiselect questions from list endpoint when params are sent', async () => { - const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionId), - }); - const multiselectQuestionB = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionId), - }); + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions?`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); - const queryParams: MultiselectQuestionQueryParams = { - filter: [ - { - $comparison: Compare['='], - jurisdiction: jurisdictionId, - }, - ], - }; - const query = stringify(queryParams as any); - - const res = await request(app.getHttpServer()) - .get(`/multiselectQuestions?${query}`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) - .expect(200); - - expect(res.body.length).toBeGreaterThanOrEqual(2); - const multiselectQuestions = res.body.map((value) => value.text); - expect(multiselectQuestions).toContain(multiselectQuestionA.text); - expect(multiselectQuestions).toContain(multiselectQuestionB.text); - }); + expect(res.body.length).toBeGreaterThanOrEqual(2); + const multiselectQuestions = res.body.map((value) => value.text); + expect(multiselectQuestions).toContain(multiselectQuestion.text); + expect(multiselectQuestions).toContain(multiselectQuestionB.text); + }); - it('should throw error when retrieve endpoint is hit with nonexistent id', async () => { - const id = randomUUID(); - const res = await request(app.getHttpServer()) - .get(`/multiselectQuestions/${id}`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) - .expect(404); - expect(res.body.message).toEqual( - `multiselectQuestionId ${id} was requested but not found`, - ); - }); + it('should get multiselect questions from list endpoint when params are sent', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId), + }); + const multiselectQuestionB = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId), + }); - it('should get multiselect question when retrieve endpoint is called and id exists', async () => { - const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionId), - }); + const queryParams: MultiselectQuestionQueryParams = { + filter: [ + { + $comparison: Compare['='], + jurisdiction: jurisdictionId, + }, + ], + }; + const query = stringify(queryParams as any); - const res = await request(app.getHttpServer()) - .get(`/multiselectQuestions/${multiselectQuestionA.id}`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) - .expect(200); + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions?${query}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); - expect(res.body.text).toEqual(multiselectQuestionA.text); - }); + expect(res.body.length).toBeGreaterThanOrEqual(2); + const multiselectQuestions = res.body.map((value) => value.text); + expect(multiselectQuestions).toContain(multiselectQuestion.text); + expect(multiselectQuestions).toContain(multiselectQuestionB.text); + }); + }); - it('should create a multiselect question', async () => { - const res = await request(app.getHttpServer()) - .post('/multiselectQuestions') - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - text: 'example text', - subText: 'example subText', - description: 'example description', - links: [ - { - title: 'title 1', - url: 'https://title-1.com', - }, - { - title: 'title 2', - url: 'https://title-2.com', - }, - ], - jurisdictions: [{ id: jurisdictionId }], - options: [ - { - text: 'example option text 1', - ordinal: 1, - description: 'example option description 1', + describe('create', () => { + it('should create a multiselect question', async () => { + const res = await request(app.getHttpServer()) + .post('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + text: 'example text', + subText: 'example subText', + description: 'example description', links: [ { - title: 'title 3', - url: 'https://title-3.com', + title: 'title 1', + url: 'https://title-1.com', + }, + { + title: 'title 2', + url: 'https://title-2.com', }, ], - collectAddress: true, - exclusive: false, - }, - { - text: 'example option text 2', - ordinal: 2, - description: 'example option description 2', - links: [ + jurisdictions: [{ id: jurisdictionId }], + options: [ + { + text: 'example option text 1', + ordinal: 1, + description: 'example option description 1', + links: [ + { + title: 'title 3', + url: 'https://title-3.com', + }, + ], + collectAddress: true, + exclusive: false, + }, { - title: 'title 4', - url: 'https://title-4.com', + text: 'example option text 2', + ordinal: 2, + description: 'example option description 2', + links: [ + { + title: 'title 4', + url: 'https://title-4.com', + }, + ], + collectAddress: true, + exclusive: false, }, ], - collectAddress: true, - exclusive: false, - }, - ], - optOutText: 'example optOutText', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, - } as MultiselectQuestionCreate) - .set('Cookie', cookies) - .expect(201); - - expect(res.body.text).toEqual('example text'); - }); + optOutText: 'example optOutText', + hideFromListing: false, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, + } as MultiselectQuestionCreate) + .set('Cookie', cookies) + .expect(201); - it('should throw error when update endpoint is hit with nonexistent id', async () => { - const id = randomUUID(); - const res = await request(app.getHttpServer()) - .put(`/multiselectQuestions/${id}`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - id: id, - text: 'example text', - subText: 'example subText', - description: 'example description', - links: [ - { - title: 'title 1', - url: 'https://title-1.com', - }, - { - title: 'title 2', - url: 'https://title-2.com', - }, - ], - jurisdictions: [{ id: jurisdictionId }], - options: [ - { - text: 'example option text 1', - ordinal: 1, - description: 'example option description 1', + expect(res.body.text).toEqual('example text'); + }); + }); + + describe('update', () => { + it('should throw error when update endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + text: 'example text', + subText: 'example subText', + description: 'example description', links: [ { - title: 'title 3', - url: 'https://title-3.com', + title: 'title 1', + url: 'https://title-1.com', + }, + { + title: 'title 2', + url: 'https://title-2.com', + }, + ], + jurisdictions: [{ id: jurisdictionId }], + options: [ + { + text: 'example option text 1', + ordinal: 1, + description: 'example option description 1', + links: [ + { + title: 'title 3', + url: 'https://title-3.com', + }, + ], + collectAddress: true, + exclusive: false, + }, + { + text: 'example option text 2', + ordinal: 2, + description: 'example option description 2', + links: [ + { + title: 'title 4', + url: 'https://title-4.com', + }, + ], + collectAddress: true, + exclusive: false, }, ], - collectAddress: true, - exclusive: false, - }, - { - text: 'example option text 2', - ordinal: 2, - description: 'example option description 2', + optOutText: 'example optOutText', + hideFromListing: false, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, + } as MultiselectQuestionUpdate) + .set('Cookie', cookies) + .expect(404); + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should update multiselect question', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId), + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + text: 'example text', + subText: 'example subText', + description: 'example description', links: [ { - title: 'title 4', - url: 'https://title-4.com', + title: 'title 1', + url: 'https://title-1.com', + }, + { + title: 'title 2', + url: 'https://title-2.com', + }, + ], + jurisdictions: [{ id: jurisdictionId }], + options: [ + { + text: 'example option text 1', + ordinal: 1, + description: 'example option description 1', + links: [ + { + title: 'title 3', + url: 'https://title-3.com', + }, + ], + collectAddress: true, + exclusive: false, + }, + { + text: 'example option text 2', + ordinal: 2, + description: 'example option description 2', + links: [ + { + title: 'title 4', + url: 'https://title-4.com', + }, + ], + collectAddress: true, + exclusive: false, }, ], - collectAddress: true, - exclusive: false, - }, - ], - optOutText: 'example optOutText', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, - } as MultiselectQuestionUpdate) - .set('Cookie', cookies) - .expect(404); - expect(res.body.message).toEqual( - `multiselectQuestionId ${id} was requested but not found`, - ); + optOutText: 'example optOutText', + hideFromListing: false, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, + } as MultiselectQuestionUpdate) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.text).toEqual('example text'); + }); + }); + + describe('delete', () => { + it('should throw error when delete endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .delete(`/multiselectQuestions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + } as IdDTO) + .set('Cookie', cookies) + .expect(404); + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should delete multiselect question', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId), + }); + + const res = await request(app.getHttpServer()) + .delete(`/multiselectQuestions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + }); + }); + + describe('retrieve', () => { + it('should throw error when retrieve endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions/${id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(404); + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should get multiselect question when retrieve endpoint is called and id exists', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId), + }); + + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions/${multiselectQuestion.id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.text).toEqual(multiselectQuestion.text); + }); + }); }); - it('should update multiselect question', async () => { - const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionId), + describe('v2 msq implementation enabled', () => { + let jurisdictionId: string; + let cookies = ''; + beforeAll(async () => { + const jurisdiction = await prisma.jurisdictions.create({ + data: jurisdictionFactory('enableV2', { + featureFlags: [FeatureFlagEnum.enableV2MSQ], + }), + }); + jurisdictionId = jurisdiction.id; + + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + mfaEnabled: false, + confirmedAt: new Date(), + }), + }); + const resLogIn = await request(app.getHttpServer()) + .post('/auth/login') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + email: storedUser.email, + password: 'Abcdef12345!', + } as Login) + .expect(201); + + cookies = resLogIn.headers['set-cookie']; + }); + + describe('list', () => { + it('should get multiselect questions from list endpoint when no params are sent', async () => { + const jurisdictionB = await prisma.jurisdictions.create({ + data: jurisdictionFactory('enableV2 juris list', { + featureFlags: [FeatureFlagEnum.enableV2MSQ], + }), + }); + + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionB.id, {}, true), + }); + const multiselectQuestionB = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionB.id, {}, true), + }); + + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions?`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.length).toBeGreaterThanOrEqual(2); + const multiselectQuestions = res.body.map((value) => value.name); + expect(multiselectQuestions).toContain(multiselectQuestionA.name); + expect(multiselectQuestions).toContain(multiselectQuestionB.name); + }); + + it('should get multiselect questions from list endpoint when params are sent', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId, {}, true), + }); + const multiselectQuestionB = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId, {}, true), + }); + + const queryParams: MultiselectQuestionQueryParams = { + filter: [ + { + $comparison: Compare['='], + jurisdiction: jurisdictionId, + }, + ], + }; + const query = stringify(queryParams as any); + + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions?${query}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.length).toBeGreaterThanOrEqual(2); + const multiselectQuestions = res.body.map((value) => value.name); + expect(multiselectQuestions).toContain(multiselectQuestionA.name); + expect(multiselectQuestions).toContain(multiselectQuestionB.name); + }); }); - const res = await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - id: multiselectQuestionA.id, - text: 'example text', - subText: 'example subText', - description: 'example description', - links: [ - { - title: 'title 1', - url: 'https://title-1.com', - }, - { - title: 'title 2', - url: 'https://title-2.com', - }, - ], - jurisdictions: [{ id: jurisdictionId }], - options: [ - { - text: 'example option text 1', - ordinal: 1, - description: 'example option description 1', + describe('create', () => { + it('should create a multiselect question', async () => { + const res = await request(app.getHttpServer()) + .post('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + description: 'example description', + isExclusive: true, + jurisdiction: { id: jurisdictionId }, links: [ { - title: 'title 3', - url: 'https://title-3.com', + title: 'title 1', + url: 'https://title-1.com', + }, + { + title: 'title 2', + url: 'https://title-2.com', }, ], - collectAddress: true, - exclusive: false, - }, - { - text: 'example option text 2', - ordinal: 2, - description: 'example option description 2', - links: [ + multiselectOptions: [ { - title: 'title 4', - url: 'https://title-4.com', + description: 'example option description', + name: 'example option name', + ordinal: 1, + // TODO: Can be removed after MSQ refactor + text: 'example option text', }, ], - collectAddress: true, - exclusive: false, - }, - ], - optOutText: 'example optOutText', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, - } as MultiselectQuestionUpdate) - .set('Cookie', cookies) - .expect(200); - - expect(res.body.text).toEqual('example text'); - }); + name: 'example name', + status: MultiselectQuestionsStatusEnum.draft, + subText: 'example subText', - it('should throw error when delete endpoint is hit with nonexistent id', async () => { - const id = randomUUID(); - const res = await request(app.getHttpServer()) - .delete(`/multiselectQuestions`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - id: id, - } as IdDTO) - .set('Cookie', cookies) - .expect(404); - expect(res.body.message).toEqual( - `multiselectQuestionId ${id} was requested but not found`, - ); - }); + // TODO: Can be removed after MSQ refactor + jurisdictions: [{ id: jurisdictionId }], + text: 'example text', + } as MultiselectQuestionCreate) + .set('Cookie', cookies) + .expect(201); + + expect(res.body.name).toEqual('example name'); + }); + }); + + describe('update', () => { + it('should throw error when update endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + isExclusive: true, + jurisdiction: { id: jurisdictionId }, + name: 'example name', + status: MultiselectQuestionsStatusEnum.visible, + + // TODO: Can be removed after MSQ refactor + jurisdictions: [{ id: jurisdictionId }], + text: 'example text', + } as MultiselectQuestionUpdate) + .set('Cookie', cookies) + .expect(404); + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should update multiselect question', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + multiselectOptions: { + createMany: { + data: [ + { + name: 'example option name1', + ordinal: 1, + }, + { + isOptOut: true, + name: 'example option name2', + ordinal: 2, + }, + ], + }, + }, + }, + }, + true, + ), + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + applicationSection: multiselectQuestion.applicationSection, + isExclusive: multiselectQuestion.isExclusive, + jurisdiction: { id: multiselectQuestion.jurisdictionId }, + multiselectOptions: [ + { + description: 'example option description', + name: 'example option name', + ordinal: 1, + // TODO: Can be removed after MSQ refactor + text: 'example option text', + }, + ], + name: 'example name', + status: MultiselectQuestionsStatusEnum.visible, + + // TODO: Can be removed after MSQ refactor + jurisdictions: [{ id: multiselectQuestion.jurisdictionId }], + text: multiselectQuestion.text, + } as MultiselectQuestionUpdate) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.name).toEqual('example name'); + }); + }); + + describe('delete', () => { + it('should throw error when delete endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .delete(`/multiselectQuestions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + } as IdDTO) + .set('Cookie', cookies) + .expect(404); + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should delete multiselect question', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId, {}, true), + }); + + const res = await request(app.getHttpServer()) + .delete(`/multiselectQuestions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + }); + }); + + describe('reActivate', () => { + it('should re-activate a multiselectQuestion in the toRetire status', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + + const updatedData = await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestion.id }, + }); + expect(updatedData.status).toEqual( + MultiselectQuestionsStatusEnum.active, + ); + }); + + it('should throw error when reActivate endpoint is hit with an multiselectQuestion not in toRetire status', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.retired, + }, + }, + true, + ), + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(400); + + expect(res.body.message).toEqual("status 'retired' cannot be changed"); + }); + + it('should throw error when reActivate endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + } as IdDTO) + .set('Cookie', cookies) + .expect(404); + + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + }); + + describe('retire', () => { + it('should retire a multiselectQuestion in the active status with no active listings', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); - it('should delete multiselect question', async () => { - const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionId), + const listingData = await listingFactory(jurisdictionId, prisma, { + multiselectQuestions: [multiselectQuestion], + status: ListingsStatusEnum.closed, + }); + await prisma.listings.create({ + data: listingData, + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + + const updatedData = await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestion.id }, + }); + expect(updatedData.status).toEqual( + MultiselectQuestionsStatusEnum.retired, + ); + }); + + it('should set toRetire a multiselectQuestion in the active status with active listings', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + const listingData = await listingFactory(jurisdictionId, prisma, { + multiselectQuestions: [multiselectQuestion], + status: ListingsStatusEnum.active, + }); + await prisma.listings.create({ + data: listingData, + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + + const updatedData = await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestion.id }, + }); + expect(updatedData.status).toEqual( + MultiselectQuestionsStatusEnum.toRetire, + ); + }); + + it('should throw error when retire endpoint is hit with an multiselectQuestion not in active status', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId, {}, true), + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(400); + + expect(res.body.message).toEqual( + "status 'draft' can only change to 'visible'", + ); + }); + + it('should throw error when retire endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + } as IdDTO) + .set('Cookie', cookies) + .expect(404); + + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); }); - const res = await request(app.getHttpServer()) - .delete(`/multiselectQuestions`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - id: multiselectQuestionA.id, - } as IdDTO) - .set('Cookie', cookies) - .expect(200); + describe('retireMultiselectQuestions', () => { + it('should retire multiselectQuestions in toRetire status with no active listings', async () => { + const jurisdictionAll = await prisma.jurisdictions.create({ + data: jurisdictionFactory('enableV2 juris retire all', { + featureFlags: [FeatureFlagEnum.enableV2MSQ], + }), + }); + const multiselectQuestionClosedListing = + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionAll.id, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + const multiselectQuestionNoListing = + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionAll.id, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + const listingData = await listingFactory(jurisdictionId, prisma, { + multiselectQuestions: [multiselectQuestionClosedListing], + status: ListingsStatusEnum.closed, + }); + await prisma.listings.create({ + data: listingData, + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); - expect(res.body.success).toEqual(true); + expect(res.body.success).toEqual(true); + + const updatedDataA = await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestionClosedListing.id }, + }); + expect(updatedDataA.status).toEqual( + MultiselectQuestionsStatusEnum.retired, + ); + + const updatedDataB = await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestionNoListing.id }, + }); + expect(updatedDataB.status).toEqual( + MultiselectQuestionsStatusEnum.retired, + ); + }); + + it('should not retire multiselectQuestions in toRetire status with active listings', async () => { + const jurisdictionSome = await prisma.jurisdictions.create({ + data: jurisdictionFactory('enableV2 juris retire some', { + featureFlags: [FeatureFlagEnum.enableV2MSQ], + }), + }); + const multiselectQuestionActiveListing = + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionSome.id, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + const multiselectQuestionNoListing = + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionSome.id, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + const listingData = await listingFactory(jurisdictionId, prisma, { + multiselectQuestions: [multiselectQuestionActiveListing], + status: ListingsStatusEnum.active, + }); + await prisma.listings.create({ + data: listingData, + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + + const updatedDataActiveListing = + await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestionActiveListing.id }, + }); + expect(updatedDataActiveListing.status).toEqual( + MultiselectQuestionsStatusEnum.toRetire, + ); + + const updatedDataNoListing = + await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestionNoListing.id }, + }); + expect(updatedDataNoListing.status).toEqual( + MultiselectQuestionsStatusEnum.retired, + ); + }); + }); + + describe('retrieve', () => { + it('should throw error when retrieve endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions/${id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(404); + + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should get multiselect question when retrieve endpoint is called and id exists', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId, {}, true), + }); + + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions/${multiselectQuestion.id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.name).toEqual(multiselectQuestion.name); + }); + }); }); }); diff --git a/api/test/integration/permission-tests/helpers.ts b/api/test/integration/permission-tests/helpers.ts index 10dbf61f19..a1953abcb3 100644 --- a/api/test/integration/permission-tests/helpers.ts +++ b/api/test/integration/permission-tests/helpers.ts @@ -10,6 +10,7 @@ import { ListingEventsTypeEnum, ListingsStatusEnum, MultiselectQuestionsApplicationSectionEnum, + MultiselectQuestionsStatusEnum, Prisma, ReviewOrderTypeEnum, UnitTypeEnum, @@ -169,9 +170,10 @@ export const buildMultiselectQuestionCreateMock = ( jurisId: string, ): MultiselectQuestionCreate => { return { - text: 'example text', - subText: 'example subText', + applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, description: 'example description', + hideFromListing: false, + jurisdictions: [{ id: jurisId }], links: [ { title: 'title 1', @@ -182,7 +184,6 @@ export const buildMultiselectQuestionCreateMock = ( url: 'https://title-2.com', }, ], - jurisdictions: [{ id: jurisId }], options: [ { text: 'example option text 1', @@ -212,8 +213,9 @@ export const buildMultiselectQuestionCreateMock = ( }, ], optOutText: 'example optOutText', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, + subText: 'example subText', + text: 'example text', }; }; @@ -223,51 +225,7 @@ export const buildMultiselectQuestionUpdateMock = ( ): MultiselectQuestionUpdate => { return { id, - text: 'example text', - subText: 'example subText', - description: 'example description', - links: [ - { - title: 'title 1', - url: 'https://title-1.com', - }, - { - title: 'title 2', - url: 'https://title-2.com', - }, - ], - jurisdictions: [{ id: jurisId }], - options: [ - { - text: 'example option text 1', - ordinal: 1, - description: 'example option description 1', - links: [ - { - title: 'title 3', - url: 'https://title-3.com', - }, - ], - collectAddress: true, - exclusive: false, - }, - { - text: 'example option text 2', - ordinal: 2, - description: 'example option description 2', - links: [ - { - title: 'title 4', - url: 'https://title-4.com', - }, - ], - collectAddress: true, - exclusive: false, - }, - ], - optOutText: 'example optOutText', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + ...buildMultiselectQuestionCreateMock(jurisId), }; }; diff --git a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts index dd6a48ad1d..ed383716c0 100644 --- a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -828,7 +833,7 @@ describe('Testing Permissioning of endpoints as Admin User', () => { }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( buildMultiselectQuestionUpdateMock( @@ -864,6 +869,60 @@ describe('Testing Permissioning of endpoints as Admin User', () => { expect(activityLogResult).not.toBeNull(); }); + + it('should succeed for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + }); }); describe('Testing user endpoints', () => { diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts index a3537f6e87..295124eff9 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -81,7 +86,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], @@ -100,17 +105,17 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr app.use(cookieParser()); await app.init(); - jurisId = await generateJurisdiction( + jurisdictionId = await generateJurisdiction( prisma, 'correct jadmin permission juris', ); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); const storedUser = await prisma.userAccounts.create({ data: await userFactory({ roles: { isJurisdictionalAdmin: true }, - jurisdictionIds: [jurisId], + jurisdictionIds: [jurisdictionId], mfaEnabled: false, confirmedAt: new Date(), }), @@ -135,10 +140,10 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -151,7 +156,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -165,14 +170,14 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(201); }); it('should succeed for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -185,7 +190,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -214,7 +219,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for list endpoint', async () => { - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -232,7 +237,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -259,7 +264,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -299,7 +304,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -328,7 +333,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -366,7 +371,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -412,7 +417,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -436,7 +441,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for csv endpoint & create an activity log entry', async () => { const application = await applicationFactory(); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { applications: [application], }); const listing1Created = await prisma.listings.create({ @@ -481,7 +486,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -490,7 +495,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -512,9 +517,11 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:3')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:3'), + ) .set('Cookie', cookies) .expect(403); }); @@ -548,7 +555,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -562,7 +569,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -570,7 +577,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -584,7 +591,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -804,7 +811,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -818,21 +825,24 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(201); }); it('should succeed for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) .expect(200); @@ -840,7 +850,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for delete endpoint & create an activity log entry', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -862,6 +872,60 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr expect(activityLogResult).not.toBeNull(); }); + + it('should succeed for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + }); }); describe('Testing user endpoints', () => { @@ -875,7 +939,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve endpoint', async () => { const userA = await prisma.userAccounts.create({ - data: await userFactory({ jurisdictionIds: [jurisId] }), + data: await userFactory({ jurisdictionIds: [jurisdictionId] }), }); await request(app.getHttpServer()) @@ -887,7 +951,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for update endpoint', async () => { const userA = await prisma.userAccounts.create({ - data: await userFactory({ jurisdictionIds: [jurisId] }), + data: await userFactory({ jurisdictionIds: [jurisdictionId] }), }); await request(app.getHttpServer()) @@ -897,7 +961,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr id: userA.id, firstName: 'New User First Name', lastName: 'New User Last Name', - jurisdictions: [{ id: jurisId } as IdDTO], + jurisdictions: [{ id: jurisdictionId } as IdDTO], } as UserUpdate) .set('Cookie', cookies) .expect(200); @@ -906,7 +970,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for delete endpoint', async () => { const userA = await prisma.userAccounts.create({ data: await userFactory({ - jurisdictionIds: [jurisId], + jurisdictionIds: [jurisdictionId], roles: { isJurisdictionalAdmin: true }, }), }); @@ -1019,7 +1083,10 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr .post(`/user/invite`) .send( // builds an invite for an admin - buildUserInviteMock(jurisId, 'partnerUser+jurisCorrect@email.com'), + buildUserInviteMock( + jurisdictionId, + 'partnerUser+jurisCorrect@email.com', + ), ) .set('Cookie', cookies) .set({ passkey: process.env.API_PASS_KEY || '' }) @@ -1064,13 +1131,13 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieveListings endpoint', async () => { const multiselectQuestion1 = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, }), }); - const listingA = await listingFactory(jurisId, prisma, { + const listingA = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [multiselectQuestion1], }); const listingACreated = await prisma.listings.create({ @@ -1089,7 +1156,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for external listing endpoint', async () => { - const listingA = await listingFactory(jurisId, prisma); + const listingA = await listingFactory(jurisdictionId, prisma); const listingACreated = await prisma.listings.create({ data: listingA, include: { @@ -1104,7 +1171,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for delete endpoint & create an activity log entry', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { noImage: true, }); const listing = await prisma.listings.create({ @@ -1132,12 +1199,16 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for update endpoint & create an activity log entry', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); - const val = await constructFullListingData(prisma, listing.id, jurisId); + const val = await constructFullListingData( + prisma, + listing.id, + jurisdictionId, + ); await request(app.getHttpServer()) .put(`/listings/${listing.id}`) @@ -1158,7 +1229,11 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for create endpoint & create an activity log entry', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); const res = await request(app.getHttpServer()) .post('/listings') @@ -1179,7 +1254,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for duplicate endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1250,7 +1325,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for retrieve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1279,7 +1354,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for resolve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1317,7 +1392,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for reset confirmation endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1372,7 +1447,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should error as forbidden for lottery status endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { status: 'closed', }); const listing = await prisma.listings.create({ diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts index b09f8864fe..503f96eccc 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -41,7 +46,6 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; -import { permissionActions } from '../../../src/enums/permissions/permission-actions-enum'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { generateJurisdiction, @@ -81,7 +85,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -106,8 +110,11 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron 'jadmin permission juris', ); - jurisId = await generateJurisdiction(prisma, 'wrong permission juris'); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + jurisdictionId = await generateJurisdiction( + prisma, + 'wrong permission juris', + ); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); const storedUser = await prisma.userAccounts.create({ data: await userFactory({ @@ -126,7 +133,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron } as Login) .expect(201); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); cookies = resLogIn.headers['set-cookie']; }); @@ -139,10 +146,10 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -155,7 +162,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -169,14 +176,14 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(201); }); it('should succeed for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -189,7 +196,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -218,7 +225,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should succeed for list endpoint', async () => { - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -236,7 +243,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -263,7 +270,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -293,7 +300,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -322,7 +329,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -350,7 +357,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -387,7 +394,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -451,7 +458,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -460,7 +467,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -482,9 +489,11 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:4')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:4'), + ) .set('Cookie', cookies) .expect(403); }); @@ -518,7 +527,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -532,7 +541,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -540,7 +549,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) .put(`/reservedCommunityTypes/${reservedCommunityTypeA.id}`) @@ -553,7 +562,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -773,7 +782,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -783,33 +792,36 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron .expect(200); }); - it('should succed for create endpoint', async () => { + it('should error as forbidden for create endpoint', async () => { await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) - .expect(201); + .expect(403); }); - it('should succeed for update endpoint', async () => { + it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) - .expect(200); + .expect(403); }); - it('should succeed for delete endpoint & create an activity log entry', async () => { + it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -819,17 +831,61 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron id: multiselectQuestionA.id, } as IdDTO) .set('Cookie', cookies) - .expect(200); + .expect(403); + }); - const activityLogResult = await prisma.activityLog.findFirst({ - where: { - module: 'multiselectQuestion', - action: permissionActions.delete, - recordId: multiselectQuestionA.id, - }, + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), }); - expect(activityLogResult).not.toBeNull(); + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should succeed for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); }); }); @@ -1031,13 +1087,13 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieveListings endpoint', async () => { const multiselectQuestion1 = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, }), }); - const listingA = await listingFactory(jurisId, prisma, { + const listingA = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [multiselectQuestion1], }); const listingACreated = await prisma.listings.create({ @@ -1056,7 +1112,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should succeed for external listing endpoint', async () => { - const listingA = await listingFactory(jurisId, prisma); + const listingA = await listingFactory(jurisdictionId, prisma); const listingACreated = await prisma.listings.create({ data: listingA, include: { @@ -1071,7 +1127,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should error as forbidden for delete endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1087,12 +1143,16 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should error as forbidden for update endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); - const val = await constructFullListingData(prisma, listing.id, jurisId); + const val = await constructFullListingData( + prisma, + listing.id, + jurisdictionId, + ); await request(app.getHttpServer()) .put(`/listings/${listing.id}`) @@ -1103,7 +1163,11 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should error as forbidden for create endpoint', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); await request(app.getHttpServer()) .post('/listings') @@ -1114,7 +1178,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should error as forbidden for duplicate endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1185,7 +1249,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should succeed for retrieve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1214,7 +1278,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should succeed for resolve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1252,7 +1316,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should succeed for reset confirmation endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1308,7 +1372,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should error as forbidden for lottery status endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { status: 'closed', }); const listing = await prisma.listings.create({ diff --git a/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts index 17bf1a1f5e..fd0fd67345 100644 --- a/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -80,7 +85,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], @@ -99,14 +104,14 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in app.use(cookieParser()); await app.init(); - jurisId = await generateJurisdiction(prisma, 'permission juris 80'); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + jurisdictionId = await generateJurisdiction(prisma, 'permission juris 80'); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); const storedUser = await prisma.userAccounts.create({ data: await userFactory({ roles: { isLimitedJurisdictionalAdmin: true }, - jurisdictionIds: [jurisId], + jurisdictionIds: [jurisdictionId], mfaEnabled: false, confirmedAt: new Date(), }), @@ -131,10 +136,10 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -147,7 +152,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -161,14 +166,14 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(201); }); it('should succeed for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -181,7 +186,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -210,7 +215,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for list endpoint for listing with no applications', async () => { - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -228,7 +233,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -255,7 +260,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -285,7 +290,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -314,7 +319,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -340,7 +345,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -376,7 +381,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -400,7 +405,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for csv endpoint', async () => { const application = await applicationFactory(); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { applications: [application], }); const listing1Created = await prisma.listings.create({ @@ -436,7 +441,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -445,7 +450,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -467,9 +472,11 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:3')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:3'), + ) .set('Cookie', cookies) .expect(403); }); @@ -503,7 +510,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -517,7 +524,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -525,7 +532,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -539,7 +546,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -759,7 +766,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -773,21 +780,24 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) .expect(403); @@ -795,7 +805,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -807,6 +817,60 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { @@ -820,7 +884,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for retrieve endpoint', async () => { const userA = await prisma.userAccounts.create({ - data: await userFactory({ jurisdictionIds: [jurisId] }), + data: await userFactory({ jurisdictionIds: [jurisdictionId] }), }); await request(app.getHttpServer()) @@ -832,7 +896,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for update endpoint', async () => { const userA = await prisma.userAccounts.create({ - data: await userFactory({ jurisdictionIds: [jurisId] }), + data: await userFactory({ jurisdictionIds: [jurisdictionId] }), }); await request(app.getHttpServer()) @@ -842,7 +906,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in id: userA.id, firstName: 'New User First Name', lastName: 'New User Last Name', - jurisdictions: [{ id: jurisId } as IdDTO], + jurisdictions: [{ id: jurisdictionId } as IdDTO], } as UserUpdate) .set('Cookie', cookies) .expect(403); @@ -850,7 +914,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for delete endpoint', async () => { const userA = await prisma.userAccounts.create({ - data: await userFactory({ jurisdictionIds: [jurisId] }), + data: await userFactory({ jurisdictionIds: [jurisdictionId] }), }); await request(app.getHttpServer()) @@ -958,7 +1022,10 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in .post(`/user/invite`) .send( // builds an invite for an admin - buildUserInviteMock(jurisId, 'partnerUser+jurisCorrect@email.com'), + buildUserInviteMock( + jurisdictionId, + 'partnerUser+jurisCorrect@email.com', + ), ) .set('Cookie', cookies) .set({ passkey: process.env.API_PASS_KEY || '' }) @@ -985,13 +1052,13 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieveListings endpoint', async () => { const multiselectQuestion1 = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, }), }); - const listingA = await listingFactory(jurisId, prisma, { + const listingA = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [multiselectQuestion1], }); const listingACreated = await prisma.listings.create({ @@ -1010,7 +1077,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for external listing endpoint', async () => { - const listingA = await listingFactory(jurisId, prisma); + const listingA = await listingFactory(jurisdictionId, prisma); const listingACreated = await prisma.listings.create({ data: listingA, include: { @@ -1025,7 +1092,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for delete endpoint & create an activity log entry', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { noImage: true, }); const listing = await prisma.listings.create({ @@ -1053,14 +1120,18 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for update endpoint & create an activity log entry when user is not updating dates', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { applicationDueDate: new Date(), }); const listing = await prisma.listings.create({ data: listingData, }); - const val = await constructFullListingData(prisma, listing.id, jurisId); + const val = await constructFullListingData( + prisma, + listing.id, + jurisdictionId, + ); val.reviewOrderType = listing.reviewOrderType; val.applicationDueDate = listing.applicationDueDate; @@ -1083,7 +1154,11 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for create endpoint & create an activity log entry', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); const res = await request(app.getHttpServer()) .post('/listings') @@ -1138,7 +1213,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for lottery status endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { status: 'closed', }); const listing = await prisma.listings.create({ @@ -1200,7 +1275,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for retrieve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1229,7 +1304,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for resolve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1267,7 +1342,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for reset confirmation endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); diff --git a/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts index 1d2872414e..43e2c8e58c 100644 --- a/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -80,7 +85,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -105,11 +110,11 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in 'wrong limited jadmin permission juris', ); - jurisId = await generateJurisdiction( + jurisdictionId = await generateJurisdiction( prisma, 'wrong permission limited juris', ); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); const storedUser = await prisma.userAccounts.create({ data: await userFactory({ @@ -128,7 +133,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in } as Login) .expect(201); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); cookies = resLogIn.headers['set-cookie']; }); @@ -141,10 +146,10 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -157,7 +162,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -171,14 +176,14 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(201); }); it('should succeed for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -191,7 +196,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -220,7 +225,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for list endpoint', async () => { - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -238,7 +243,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -265,7 +270,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -295,7 +300,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -324,7 +329,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -352,7 +357,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -389,7 +394,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -453,7 +458,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -462,7 +467,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -484,9 +489,11 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:4')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:4'), + ) .set('Cookie', cookies) .expect(403); }); @@ -520,7 +527,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -534,7 +541,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -542,7 +549,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) .put(`/reservedCommunityTypes/${reservedCommunityTypeA.id}`) @@ -555,7 +562,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -775,7 +782,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -789,21 +796,24 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) .expect(403); @@ -811,7 +821,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -823,6 +833,60 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { @@ -1011,13 +1075,13 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieveListings endpoint', async () => { const multiselectQuestion1 = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, }), }); - const listingA = await listingFactory(jurisId, prisma, { + const listingA = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [multiselectQuestion1], }); const listingACreated = await prisma.listings.create({ @@ -1036,7 +1100,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for external listing endpoint', async () => { - const listingA = await listingFactory(jurisId, prisma); + const listingA = await listingFactory(jurisdictionId, prisma); const listingACreated = await prisma.listings.create({ data: listingA, include: { @@ -1051,7 +1115,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for delete endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1067,12 +1131,16 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for update endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); - const val = await constructFullListingData(prisma, listing.id, jurisId); + const val = await constructFullListingData( + prisma, + listing.id, + jurisdictionId, + ); await request(app.getHttpServer()) .put(`/listings/${listing.id}`) @@ -1083,7 +1151,11 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for create endpoint', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); await request(app.getHttpServer()) .post('/listings') @@ -1094,7 +1166,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for duplicate endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1165,7 +1237,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for retrieve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1194,7 +1266,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for resolve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1232,7 +1304,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for reset confirmation endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1288,7 +1360,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for lottery status endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { status: 'closed', }); const listing = await prisma.listings.create({ diff --git a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts index 679237098a..64cac811bc 100644 --- a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -738,7 +743,7 @@ describe('Testing Permissioning of endpoints as logged out user', () => { }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( buildMultiselectQuestionUpdateMock( @@ -764,6 +769,60 @@ describe('Testing Permissioning of endpoints as logged out user', () => { .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { diff --git a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts index ee08ede246..d11775ec51 100644 --- a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts @@ -6,6 +6,7 @@ import { stringify } from 'qs'; import { FlaggedSetStatusEnum, ListingsStatusEnum, + MultiselectQuestionsStatusEnum, RuleEnum, UnitTypeEnum, } from '@prisma/client'; @@ -87,7 +88,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; let userListingId = ''; let closedUserListingId = ''; let closedUserListingId2 = ''; @@ -112,15 +113,15 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( app.use(cookieParser()); await app.init(); - jurisId = await generateJurisdiction( + jurisdictionId = await generateJurisdiction( prisma, 'correct partner permission juris', ); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); const msq = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, @@ -129,7 +130,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( listingMulitselectQuestion = msq.id; - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], digitalApp: true, }); @@ -138,7 +139,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( }); userListingId = listing.id; - const closedListingData = await listingFactory(jurisId, prisma, { + const closedListingData = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], status: ListingsStatusEnum.closed, }); @@ -147,7 +148,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( }); closedUserListingId = closedListing.id; - const listingData2 = await listingFactory(jurisId, prisma, { + const listingData2 = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], }); const listing2 = await prisma.listings.create({ @@ -155,7 +156,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( }); userListingToBeDeleted = listing2.id; - const closedListingData2 = await listingFactory(jurisId, prisma, { + const closedListingData2 = await listingFactory(jurisdictionId, prisma, { status: ListingsStatusEnum.closed, lotteryStatus: 'releasedToPartners', }); @@ -173,7 +174,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( closedUserListingId, closedUserListingId2, ], - jurisdictionIds: [jurisId], + jurisdictionIds: [jurisdictionId], mfaEnabled: false, confirmedAt: new Date(), }), @@ -200,10 +201,10 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -216,7 +217,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -230,14 +231,14 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -250,7 +251,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should error as forbidden for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -493,7 +494,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -502,7 +503,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -524,9 +525,11 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:5')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:5'), + ) .set('Cookie', cookies) .expect(403); }); @@ -560,7 +563,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -574,7 +577,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -582,7 +585,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -596,7 +599,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -816,7 +819,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -830,21 +833,24 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) .expect(403); @@ -852,7 +858,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -864,6 +870,60 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { @@ -1085,7 +1145,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( const val = await constructFullListingData( prisma, userListingId, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -1107,7 +1167,11 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( }); it('should error as forbidden for create endpoint', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); await request(app.getHttpServer()) .post('/listings') diff --git a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts index 185ab39311..50a65c6bfc 100644 --- a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts @@ -6,6 +6,7 @@ import { stringify } from 'qs'; import { FlaggedSetStatusEnum, ListingsStatusEnum, + MultiselectQuestionsStatusEnum, RuleEnum, UnitTypeEnum, } from '@prisma/client'; @@ -84,7 +85,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; let listingId = ''; let listingIdToBeDeleted = ''; let listingMulitselectQuestion = ''; @@ -109,15 +110,15 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () app.use(cookieParser()); await app.init(); - jurisId = await generateJurisdiction( + jurisdictionId = await generateJurisdiction( prisma, 'wrong partner permission juris', ); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); const msq = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, @@ -126,7 +127,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () listingMulitselectQuestion = msq.id; - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], digitalApp: true, }); @@ -135,7 +136,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () }); listingId = listing.id; - const listingData2 = await listingFactory(jurisId, prisma, { + const listingData2 = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], }); const listing2 = await prisma.listings.create({ @@ -143,14 +144,14 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () }); listingIdToBeDeleted = listing2.id; - const listingData3 = await listingFactory(jurisId, prisma, { + const listingData3 = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], }); const listing3 = await prisma.listings.create({ data: listingData3, }); - const closedListingData = await listingFactory(jurisId, prisma, { + const closedListingData = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], status: ListingsStatusEnum.closed, }); @@ -163,7 +164,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () data: await userFactory({ roles: { isPartner: true }, listings: [listing3.id], - jurisdictionIds: [jurisId], + jurisdictionIds: [jurisdictionId], mfaEnabled: false, confirmedAt: new Date(), }), @@ -188,10 +189,10 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -204,7 +205,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -218,14 +219,14 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -238,7 +239,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should error as forbidden for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -465,7 +466,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -474,7 +475,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -496,9 +497,11 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:6')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:6'), + ) .set('Cookie', cookies) .expect(403); }); @@ -532,7 +535,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -546,7 +549,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -554,7 +557,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -568,7 +571,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -788,7 +791,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -802,21 +805,24 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) .expect(403); @@ -824,7 +830,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -836,6 +842,60 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { @@ -1052,7 +1112,11 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () }); it('should error as forbidden for update endpoint', async () => { - const val = await constructFullListingData(prisma, listingId, jurisId); + const val = await constructFullListingData( + prisma, + listingId, + jurisdictionId, + ); await request(app.getHttpServer()) .put(`/listings/${listingId}`) @@ -1063,7 +1127,11 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () }); it('should error as forbidden for create endpoint', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); await request(app.getHttpServer()) .post('/listings') @@ -1074,7 +1142,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () }); it('should error as forbidden for duplicate endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); diff --git a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts index 58c062e79c..37edda7ef4 100644 --- a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -82,7 +87,7 @@ describe('Testing Permissioning of endpoints as public user', () => { let userService: UserService; let storedUserId: string; let cookies = ''; - let jurisdictionAId = ''; + let jurisdictionId = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -98,11 +103,11 @@ describe('Testing Permissioning of endpoints as public user', () => { app.use(cookieParser()); await app.init(); - jurisdictionAId = await generateJurisdiction( + jurisdictionId = await generateJurisdiction( prisma, 'public permission juris', ); - await reservedCommunityTypeFactoryAll(jurisdictionAId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); const storedUser = await prisma.userAccounts.create({ data: await userFactory({ @@ -131,10 +136,10 @@ describe('Testing Permissioning of endpoints as public user', () => { describe('Testing ami-chart endpoints', () => { it('should error as forbidden for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisdictionAId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisdictionAId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -147,7 +152,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisdictionAId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -161,14 +166,14 @@ describe('Testing Permissioning of endpoints as public user', () => { await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisdictionAId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisdictionAId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -181,7 +186,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisdictionAId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -210,7 +215,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should succeed for list endpoint', async () => { - const listing1 = await listingFactory(jurisdictionAId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -227,7 +232,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -255,7 +260,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -284,7 +289,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -312,7 +317,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -340,7 +345,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -377,7 +382,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -400,7 +405,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for csv endpoint', async () => { const application = await applicationFactory(); - const listing1 = await listingFactory(jurisdictionAId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { applications: [application], }); const listing1Created = await prisma.listings.create({ @@ -436,7 +441,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisdictionAId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -509,7 +514,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisdictionAId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -523,7 +528,7 @@ describe('Testing Permissioning of endpoints as public user', () => { await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisdictionAId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -531,7 +536,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisdictionAId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -545,7 +550,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisdictionAId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -765,7 +770,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionAId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -778,7 +783,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for create endpoint', async () => { await request(app.getHttpServer()) .post('/multiselectQuestions') - .send(buildMultiselectQuestionCreateMock(jurisdictionAId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(403); @@ -786,15 +791,15 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionAId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( buildMultiselectQuestionUpdateMock( - jurisdictionAId, + jurisdictionId, multiselectQuestionA.id, ), ) @@ -804,7 +809,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionAId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -816,6 +821,60 @@ describe('Testing Permissioning of endpoints as public user', () => { .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { @@ -1019,13 +1078,13 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should succeed for retrieveListings endpoint', async () => { const multiselectQuestion1 = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionAId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, }), }); - const listingA = await listingFactory(jurisdictionAId, prisma, { + const listingA = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [multiselectQuestion1], }); const listingACreated = await prisma.listings.create({ @@ -1044,7 +1103,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should succeed for retrieveListings endpoint', async () => { - const listingA = await listingFactory(jurisdictionAId, prisma); + const listingA = await listingFactory(jurisdictionId, prisma); const listingACreated = await prisma.listings.create({ data: listingA, include: { @@ -1059,7 +1118,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should error as forbidden for delete endpoint', async () => { - const listingData = await listingFactory(jurisdictionAId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1075,7 +1134,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should error as forbidden for update endpoint', async () => { - const listingData = await listingFactory(jurisdictionAId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1083,7 +1142,7 @@ describe('Testing Permissioning of endpoints as public user', () => { const val = await constructFullListingData( prisma, listing.id, - jurisdictionAId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -1106,7 +1165,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should error as forbidden for duplicate endpoint', async () => { - const listingData = await listingFactory(jurisdictionAId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1290,7 +1349,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should error as forbidden for lottery status endpoint', async () => { - const listingData = await listingFactory(jurisdictionAId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { status: 'closed', }); const listing = await prisma.listings.create({ diff --git a/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts index f48c0bcc07..b9d6f87dd0 100644 --- a/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -353,7 +358,7 @@ describe('Testing Permissioning of endpoints as Support Admin User', () => { .set('Cookie', cookies) .expect(201); }); - it('should succed for update endpoint', async () => { + it('should succeed for update endpoint', async () => { const unitTypeA = await unitTypeFactorySingle( prisma, UnitTypeEnum.oneBdrm, @@ -835,7 +840,7 @@ describe('Testing Permissioning of endpoints as Support Admin User', () => { }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( buildMultiselectQuestionUpdateMock( @@ -861,6 +866,60 @@ describe('Testing Permissioning of endpoints as Support Admin User', () => { .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts index dacdc35366..87300f4bce 100644 --- a/api/test/unit/services/listing.service.spec.ts +++ b/api/test/unit/services/listing.service.spec.ts @@ -5274,7 +5274,7 @@ describe('Testing listing service', () => { }); describe('Test closeListings endpoint', () => { - it('should call the purge if no listings needed to get processed', async () => { + it('should call the purge if listings needed to get processed', async () => { prisma.listings.findMany = jest.fn().mockResolvedValue([ { id: 'example id1', diff --git a/api/test/unit/services/multiselect-question.service.spec.ts b/api/test/unit/services/multiselect-question.service.spec.ts index dadfe8d1c7..e3cac19e46 100644 --- a/api/test/unit/services/multiselect-question.service.spec.ts +++ b/api/test/unit/services/multiselect-question.service.spec.ts @@ -1,20 +1,32 @@ +import { randomUUID } from 'crypto'; +import { Logger } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; import { Test, TestingModule } from '@nestjs/testing'; -import { PrismaService } from '../../../src/services/prisma.service'; -import { MultiselectQuestionService } from '../../../src/services/multiselect-question.service'; -import { MultiselectQuestionCreate } from '../../../src/dtos/multiselect-questions/multiselect-question-create.dto'; -import { MultiselectQuestionUpdate } from '../../../src/dtos/multiselect-questions/multiselect-question-update.dto'; import { + ListingsStatusEnum, MultiselectQuestionsApplicationSectionEnum, MultiselectQuestionsStatusEnum, } from '@prisma/client'; +import MultiselectQuestion from '../../../src/dtos/multiselect-questions/multiselect-question.dto'; +import { MultiselectQuestionCreate } from '../../../src/dtos/multiselect-questions/multiselect-question-create.dto'; import { MultiselectQuestionQueryParams } from '../../../src/dtos/multiselect-questions/multiselect-question-query-params.dto'; +import { MultiselectQuestionUpdate } from '../../../src/dtos/multiselect-questions/multiselect-question-update.dto'; import { Compare } from '../../../src/dtos/shared/base-filter.dto'; -import { randomUUID } from 'crypto'; +import { User } from '../../../src/dtos/users/user.dto'; +import { FeatureFlagEnum } from '../../../src/enums/feature-flags/feature-flags-enum'; +import { MultiselectQuestionService } from '../../../src/services/multiselect-question.service'; +import { PermissionService } from '../../../src/services/permission.service'; +import { PrismaService } from '../../../src/services/prisma.service'; + +const user = new User(); +const canOrThrowMock = jest.fn(); export const mockMultiselectQuestion = ( position: number, date: Date, section?: MultiselectQuestionsApplicationSectionEnum, + enableV2MSQ = false, + status: MultiselectQuestionsStatusEnum = MultiselectQuestionsStatusEnum.visible, ) => { return { id: randomUUID(), @@ -29,7 +41,17 @@ export const mockMultiselectQuestion = ( hideFromListing: false, applicationSection: section ?? MultiselectQuestionsApplicationSectionEnum.programs, - jurisdiction: { name: `jurisdiction${position}`, id: randomUUID() }, + jurisdiction: { + name: `jurisdiction${position}`, + id: randomUUID(), + featureFlags: [ + { name: FeatureFlagEnum.enableV2MSQ, active: enableV2MSQ }, + ], + }, + isExclusive: enableV2MSQ ? true : false, + name: enableV2MSQ ? `name ${position}` : `text ${position}`, + status: enableV2MSQ ? status : MultiselectQuestionsStatusEnum.draft, + multiselectOptions: [], }; }; @@ -47,7 +69,18 @@ describe('Testing multiselect question service', () => { beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [MultiselectQuestionService, PrismaService], + providers: [ + Logger, + MultiselectQuestionService, + { + provide: PermissionService, + useValue: { + canOrThrow: canOrThrowMock, + }, + }, + PrismaService, + SchedulerRegistry, + ], }).compile(); service = module.get( @@ -78,6 +111,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[0].jurisdiction.id, + name: 'jurisdiction0', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[0].jurisdiction.id, @@ -85,6 +123,11 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 0', + status: MultiselectQuestionsStatusEnum.draft, }, { id: mockedValue[1].id, @@ -99,6 +142,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[1].jurisdiction.id, + name: 'jurisdiction1', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[1].jurisdiction.id, @@ -106,6 +154,11 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 1', + status: MultiselectQuestionsStatusEnum.draft, }, { id: mockedValue[2].id, @@ -120,6 +173,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[2].jurisdiction.id, + name: 'jurisdiction2', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[2].jurisdiction.id, @@ -127,12 +185,18 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 2', + status: MultiselectQuestionsStatusEnum.draft, }, ]); expect(prisma.multiselectQuestions.findMany).toHaveBeenCalledWith({ include: { jurisdiction: true, + multiselectOptions: true, }, where: { AND: [], @@ -171,6 +235,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[0].jurisdiction.id, + name: 'jurisdiction0', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[0].jurisdiction.id, @@ -178,6 +247,11 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 0', + status: MultiselectQuestionsStatusEnum.draft, }, { id: mockedValue[1].id, @@ -192,6 +266,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[1].jurisdiction.id, + name: 'jurisdiction1', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[1].jurisdiction.id, @@ -199,6 +278,11 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 1', + status: MultiselectQuestionsStatusEnum.draft, }, { id: mockedValue[2].id, @@ -213,6 +297,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[2].jurisdiction.id, + name: 'jurisdiction2', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[2].jurisdiction.id, @@ -220,12 +309,18 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 2', + status: MultiselectQuestionsStatusEnum.draft, }, ]); expect(prisma.multiselectQuestions.findMany).toHaveBeenCalledWith({ include: { jurisdiction: true, + multiselectOptions: true, }, where: { AND: [ @@ -247,59 +342,93 @@ describe('Testing multiselect question service', () => { describe('findOne', () => { it('should get record with call to findOne()', async () => { const date = new Date(); - const mockedValue = mockMultiselectQuestion(3, date); - prisma.multiselectQuestions.findFirst = jest + const mockedMultiselectQuestion = mockMultiselectQuestion(3, date); + prisma.multiselectQuestions.findUnique = jest .fn() - .mockResolvedValue(mockedValue); + .mockResolvedValue(mockedMultiselectQuestion); expect(await service.findOne('example Id')).toEqual({ - id: mockedValue.id, - createdAt: date, - updatedAt: date, - text: 'text 3', - subText: 'subText 3', - description: 'description 3', - links: [], - options: [], - optOutText: 'optOutText 3', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + ...mockedMultiselectQuestion, + jurisdiction: { + id: mockedMultiselectQuestion.jurisdiction.id, + name: 'jurisdiction3', + ordinal: undefined, + }, jurisdictions: [ { - id: mockedValue.jurisdiction.id, + id: mockedMultiselectQuestion.jurisdiction.id, name: 'jurisdiction3', ordinal: undefined, }, ], }); - expect(prisma.multiselectQuestions.findFirst).toHaveBeenCalledWith({ + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ where: { - id: { - equals: 'example Id', + id: 'example Id', + }, + include: { + jurisdiction: true, + multiselectOptions: true, + }, + }); + }); + + it('should get record with call to findOne() with v2 enabled', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 3, + date, + MultiselectQuestionsApplicationSectionEnum.preferences, + true, + ); + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + + expect(await service.findOne('example Id')).toEqual({ + ...mockedMultiselectQuestion, + jurisdiction: { + id: mockedMultiselectQuestion.jurisdiction.id, + name: 'jurisdiction3', + ordinal: undefined, + }, + jurisdictions: [ + { + id: mockedMultiselectQuestion.jurisdiction.id, + name: 'jurisdiction3', + ordinal: undefined, }, + ], + }); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + where: { + id: 'example Id', }, include: { jurisdiction: true, + multiselectOptions: true, }, }); }); it('should error when nonexistent id is passed to findOne()', async () => { - prisma.multiselectQuestions.findFirst = jest.fn().mockResolvedValue(null); + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(null); await expect( async () => await service.findOne('example Id'), ).rejects.toThrowError(); - expect(prisma.multiselectQuestions.findFirst).toHaveBeenCalledWith({ + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ where: { - id: { - equals: 'example Id', - }, + id: 'example Id', }, include: { jurisdiction: true, + multiselectOptions: true, }, }); }); @@ -308,106 +437,225 @@ describe('Testing multiselect question service', () => { describe('create', () => { it('should create with call to create()', async () => { const date = new Date(); - const mockedValue = mockMultiselectQuestion(3, date); + const mockedValue = mockMultiselectQuestion(1, date); prisma.multiselectQuestions.create = jest .fn() .mockResolvedValue(mockedValue); + prisma.jurisdictions.findFirstOrThrow = jest + .fn() + .mockResolvedValue(mockedValue.jurisdiction); const params: MultiselectQuestionCreate = { - text: 'text 4', - subText: 'subText 4', - description: 'description 4', + applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + description: 'description 1', + hideFromListing: false, + jurisdictions: [{ id: mockedValue.jurisdiction.id }], links: [], options: [], - optOutText: 'optOutText 4', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, - jurisdictions: [{ id: 'jurisdiction id' }], + optOutText: 'optOutText 1', + status: MultiselectQuestionsStatusEnum.draft, + subText: 'subText 1', + text: 'text 1', }; - expect(await service.create(params)).toEqual({ + expect(await service.create(params, user)).toEqual({ + ...params, id: mockedValue.id, createdAt: date, updatedAt: date, - text: 'text 3', - subText: 'subText 3', - description: 'description 3', - links: [], - options: [], - optOutText: 'optOutText 3', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue.jurisdiction.id, + name: 'jurisdiction1', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue.jurisdiction.id, - name: 'jurisdiction3', + name: 'jurisdiction1', ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 1', + status: MultiselectQuestionsStatusEnum.draft, }); + delete params['jurisdictions']; expect(prisma.multiselectQuestions.create).toHaveBeenCalledWith({ data: { - applicationSection: - MultiselectQuestionsApplicationSectionEnum.programs, - description: 'description 4', - isExclusive: false, - hideFromListing: false, - jurisdiction: { connect: { id: 'jurisdiction id' } }, - links: [], + ...params, + jurisdiction: { connect: { id: mockedValue.jurisdiction.id } }, multiselectOptions: undefined, - name: 'text 4', - options: [], - optOutText: 'optOutText 4', + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 1', status: MultiselectQuestionsStatusEnum.draft, - subText: 'subText 4', - text: 'text 4', }, include: { jurisdiction: true, + multiselectOptions: true, + }, + }); + }); + + it('should create with call to create() with v2 enabled', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + prisma.multiselectQuestions.create = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + + prisma.jurisdictions.findFirstOrThrow = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion.jurisdiction); + + const params: MultiselectQuestionCreate = { + applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + description: 'description 2', + isExclusive: true, + hideFromListing: false, + jurisdiction: { id: mockedMultiselectQuestion.jurisdiction.id }, + jurisdictions: undefined, + links: [], + multiselectOptions: [], + name: 'name 2', + options: [], + optOutText: 'optOutText 2', + status: MultiselectQuestionsStatusEnum.visible, + subText: 'subText 2', + text: 'text 2', + }; + + expect(await service.create(params, user)).toEqual({ + ...params, + id: mockedMultiselectQuestion.id, + createdAt: date, + updatedAt: date, + jurisdiction: { + id: mockedMultiselectQuestion.jurisdiction.id, + name: 'jurisdiction2', + ordinal: undefined, + }, + jurisdictions: [ + { + id: mockedMultiselectQuestion.jurisdiction.id, + name: 'jurisdiction2', + ordinal: undefined, + }, + ], + }); + + delete params['jurisdictions']; + expect(prisma.multiselectQuestions.create).toHaveBeenCalledWith({ + data: { + ...params, + jurisdiction: { + connect: { id: mockedMultiselectQuestion.jurisdiction.id }, + }, + multiselectOptions: { + createMany: { + data: [], + }, + }, + }, + include: { + jurisdiction: true, + multiselectOptions: true, }, }); }); + + it('should error invalid status is passed to create()', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + prisma.multiselectQuestions.create = jest.fn().mockResolvedValue(null); + + prisma.jurisdictions.findFirstOrThrow = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion.jurisdiction); + + const params: MultiselectQuestionCreate = { + applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + description: 'description 2', + isExclusive: true, + hideFromListing: false, + jurisdiction: { id: mockedMultiselectQuestion.jurisdiction.id }, + jurisdictions: undefined, + links: [], + multiselectOptions: [], + name: 'name 2', + options: [], + optOutText: 'optOutText 2', + status: MultiselectQuestionsStatusEnum.active, + subText: 'subText 2', + text: 'text 2', + }; + + await expect( + async () => await service.create(params, user), + ).rejects.toThrowError("status must be 'draft' or 'visible' on create"); + }); }); describe('update', () => { it('should update with call to update()', async () => { const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion(3, date); - const mockedMultiselectQuestions = mockMultiselectQuestion(3, date); - - prisma.multiselectQuestions.findFirst = jest + prisma.multiselectQuestions.findUnique = jest .fn() - .mockResolvedValue(mockedMultiselectQuestions); + .mockResolvedValue(mockedMultiselectQuestion); prisma.multiselectQuestions.update = jest.fn().mockResolvedValue({ - ...mockedMultiselectQuestions, + ...mockedMultiselectQuestion, text: '', applicationSection: MultiselectQuestionsApplicationSectionEnum.preferences, jurisdiction: { name: 'jurisdiction1', id: 'jurisdictionId' }, }); + prisma.$transaction = jest.fn().mockResolvedValue([ + { + ...mockedMultiselectQuestion, + text: '', + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + jurisdiction: { name: 'jurisdiction1', id: 'jurisdictionId' }, + }, + ]); + + prisma.jurisdictions.findFirstOrThrow = jest.fn().mockResolvedValue({ + name: 'jurisdiction1', + id: 'jurisdictionId', + }); const params: MultiselectQuestionUpdate = { - id: mockedMultiselectQuestions.id, - jurisdictions: [{ name: 'jurisdiction1', id: 'jurisdictionId' }], - text: '', + id: mockedMultiselectQuestion.id, applicationSection: MultiselectQuestionsApplicationSectionEnum.preferences, + jurisdictions: [{ name: 'jurisdiction1', id: 'jurisdictionId' }], + status: MultiselectQuestionsStatusEnum.draft, + text: '', }; - expect(await service.update(params)).toEqual({ - id: mockedMultiselectQuestions.id, - createdAt: date, - updatedAt: date, - text: '', - subText: 'subText 3', - description: 'description 3', - links: [], - options: [], - optOutText: 'optOutText 3', - hideFromListing: false, - applicationSection: - MultiselectQuestionsApplicationSectionEnum.preferences, + expect(await service.update(params, user)).toEqual({ + ...mockedMultiselectQuestion, + ...params, + jurisdiction: { + id: 'jurisdictionId', + name: 'jurisdiction1', + ordinal: undefined, + }, jurisdictions: [ { id: 'jurisdictionId', @@ -417,54 +665,167 @@ describe('Testing multiselect question service', () => { ], }); - expect(prisma.multiselectQuestions.findFirst).toHaveBeenCalledWith({ + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ include: { jurisdiction: true, + multiselectOptions: true, }, where: { - id: mockedMultiselectQuestions.id, + id: mockedMultiselectQuestion.id, }, }); + delete params['id']; + delete params['jurisdictions']; expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ data: { - applicationSection: - MultiselectQuestionsApplicationSectionEnum.preferences, + ...params, isExclusive: false, links: undefined, jurisdiction: { connect: { id: 'jurisdictionId' } }, multiselectOptions: undefined, name: '', options: undefined, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + include: { + jurisdiction: true, + multiselectOptions: true, + }, + }); + }); + + it('should update with call to update() with v2 enabled', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 4, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue({ + ...mockedMultiselectQuestion, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + isExclusive: false, + jurisdiction: { name: 'jurisdiction1', id: 'jurisdictionId' }, + jurisdictions: undefined, + name: 'name change', + status: MultiselectQuestionsStatusEnum.draft, + text: '', + }); + prisma.$transaction = jest.fn().mockResolvedValue([ + { + ...mockedMultiselectQuestion, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + isExclusive: false, + jurisdiction: { name: 'jurisdiction1', id: 'jurisdictionId' }, + jurisdictions: undefined, + name: 'name change', + status: MultiselectQuestionsStatusEnum.draft, text: '', }, + ]); + + prisma.jurisdictions.findFirstOrThrow = jest.fn().mockResolvedValue({ + name: 'jurisdiction1', + id: 'jurisdictionId', + featureFlags: [{ name: FeatureFlagEnum.enableV2MSQ, active: true }], + }); + + const params: MultiselectQuestionUpdate = { + id: mockedMultiselectQuestion.id, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + isExclusive: false, + jurisdiction: { name: 'jurisdiction1', id: 'jurisdictionId' }, + jurisdictions: undefined, + name: 'name change', + status: MultiselectQuestionsStatusEnum.draft, + text: '', + }; + + expect(await service.update(params, user)).toEqual({ + ...mockedMultiselectQuestion, + ...params, + jurisdiction: { + id: 'jurisdictionId', + name: 'jurisdiction1', + ordinal: undefined, + }, + jurisdictions: [ + { + id: 'jurisdictionId', + name: 'jurisdiction1', + ordinal: undefined, + }, + ], + }); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + multiselectOptions: true, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + + delete params['id']; + delete params['jurisdictions']; + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + ...params, + links: undefined, + jurisdiction: { connect: { id: 'jurisdictionId' } }, + multiselectOptions: { + createMany: { + data: undefined, + }, + }, + options: undefined, + }, where: { - id: mockedMultiselectQuestions.id, + id: mockedMultiselectQuestion.id, }, include: { jurisdiction: true, + multiselectOptions: true, }, }); }); it('should error when nonexistent id is passed to update()', async () => { - prisma.multiselectQuestions.findFirst = jest.fn().mockResolvedValue(null); + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(null); prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + prisma.$transaction = jest.fn().mockResolvedValue([]); const params: MultiselectQuestionUpdate = { id: 'example id', - text: '', - jurisdictions: [], applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdictions: [], + status: MultiselectQuestionsStatusEnum.draft, + text: '', }; await expect( - async () => await service.update(params), + async () => await service.update(params, user), ).rejects.toThrowError(); - expect(prisma.multiselectQuestions.findFirst).toHaveBeenCalledWith({ + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ include: { jurisdiction: true, + multiselectOptions: true, }, where: { id: 'example id', @@ -473,33 +834,664 @@ describe('Testing multiselect question service', () => { }); }); - describe('update', () => { + describe('delete', () => { it('should delete with call to delete()', async () => { const date = new Date(); - const mockedValue = mockMultiselectQuestion(3, date); - prisma.multiselectQuestions.findFirst = jest + const mockedMultiselectQuestion = mockMultiselectQuestion(5, date); + prisma.multiselectQuestions.findUnique = jest .fn() - .mockResolvedValue(mockedValue); + .mockResolvedValue(mockedMultiselectQuestion); prisma.multiselectQuestions.delete = jest .fn() - .mockResolvedValue(mockedValue); + .mockResolvedValue(mockedMultiselectQuestion); + const id = mockedMultiselectQuestion.id; + + expect(await service.delete(id, user)).toEqual({ + success: true, + }); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + multiselectOptions: true, + }, + where: { + id: id, + }, + }); + + expect(prisma.multiselectQuestions.delete).toHaveBeenCalledWith({ + where: { + id: id, + }, + }); + }); + + it('should delete with call to delete() with v2 enabled', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 6, + date, + MultiselectQuestionsApplicationSectionEnum.preferences, + true, + ); + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + prisma.multiselectQuestions.delete = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); - expect(await service.delete('example Id')).toEqual({ + const id = mockedMultiselectQuestion.id; + + expect(await service.delete(id, user)).toEqual({ success: true, }); + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + multiselectOptions: true, + }, + where: { + id: id, + }, + }); + expect(prisma.multiselectQuestions.delete).toHaveBeenCalledWith({ where: { - id: 'example Id', + id: id, + }, + }); + }); + }); + + describe('validateStatusStateTransition', () => { + describe('draft transitions', () => { + it('should allow draft to draft', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.draft, + MultiselectQuestionsStatusEnum.draft, + ), + ).toBeNull; + }); + it('should allow draft to visible', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.draft, + MultiselectQuestionsStatusEnum.visible, + ), + ).toBeNull; + }); + it('should error when moving draft to a state other than visible', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.draft, + MultiselectQuestionsStatusEnum.active, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.toRetire, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.retired, + ), + ).rejects.toThrowError(); + }); + }); + + describe('visible transitions', () => { + it('should allow visible to visible', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.visible, + MultiselectQuestionsStatusEnum.visible, + ), + ).toBeNull; + }); + it('should allow visible to draft', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.visible, + MultiselectQuestionsStatusEnum.draft, + ), + ).toBeNull; + }); + it('should allow visible to active', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.visible, + MultiselectQuestionsStatusEnum.active, + ), + ).toBeNull; + }); + it('should error when moving visible to a state other than draft or active', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.visible, + MultiselectQuestionsStatusEnum.toRetire, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.retired, + ), + ).rejects.toThrowError(); + }); + }); + + describe('active transitions', () => { + it('should allow active to toRetire', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.active, + MultiselectQuestionsStatusEnum.toRetire, + ), + ).toBeNull; + }); + it('should allow active to retired', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.active, + MultiselectQuestionsStatusEnum.retired, + ), + ).toBeNull; + }); + it('should not allow active to active', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.active, + MultiselectQuestionsStatusEnum.active, + ), + ).rejects.toThrowError(); + }); + it('should error when moving active to a state other than toRetire or retired', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.active, + MultiselectQuestionsStatusEnum.draft, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.active, + MultiselectQuestionsStatusEnum.visible, + ), + ).rejects.toThrowError(); + }); + }); + + describe('toRetire transitions', () => { + it('should allow toRetire to active', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.toRetire, + MultiselectQuestionsStatusEnum.active, + ), + ).toBeNull; + }); + it('should allow toRetire to retired', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.toRetire, + MultiselectQuestionsStatusEnum.retired, + ), + ).toBeNull; + }); + it('should not allow toRetire to toRetire', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.toRetire, + MultiselectQuestionsStatusEnum.toRetire, + ), + ).rejects.toThrowError(); + }); + it('should error when moving toRetire to a state other than active or retired', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.toRetire, + MultiselectQuestionsStatusEnum.draft, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.toRetire, + MultiselectQuestionsStatusEnum.visible, + ), + ).rejects.toThrowError(); + }); + }); + + describe('retired transitions', () => { + it('should not allow retired to retired', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.retired, + ), + ).rejects.toThrowError(); + }); + it('should error when moving retired to any state', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.draft, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.visible, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.active, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.toRetire, + ), + ).rejects.toThrowError(); + }); + }); + }); + + describe('statusStateTransition', () => { + it('should update the status of a multiselectQuestion with a valid transition', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 1, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + await service.statusStateTransition( + mockedMultiselectQuestion as unknown as MultiselectQuestion, + MultiselectQuestionsStatusEnum.active, + ), + ).toBeNull; + + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.active, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + }); + + it('should error with an invalid transition', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + async () => + await service.statusStateTransition( + mockedMultiselectQuestion as unknown as MultiselectQuestion, + MultiselectQuestionsStatusEnum.retired, + ), + ).rejects.toThrowError(); + + expect(prisma.multiselectQuestions.update).not.toHaveBeenCalled(); + }); + }); + + describe('activateMany', () => { + it('should update status to active for multiselectQuestions in visible state', async () => { + const date = new Date(); + const mockedVisible = mockMultiselectQuestion( + 1, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + const mockedActive = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + MultiselectQuestionsStatusEnum.active, + ); + + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + await service.activateMany([ + mockedVisible as unknown as MultiselectQuestion, + mockedActive as unknown as MultiselectQuestion, + ]), + ).toEqual({ + success: true, + }); + + expect(prisma.multiselectQuestions.update).toHaveBeenCalledTimes(1); + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.active, + }, + where: { + id: mockedVisible.id, }, }); + }); + }); + + describe('reActivate', () => { + it('should update status to active for a multiselectQuestion in toRetire state', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 1, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + MultiselectQuestionsStatusEnum.toRetire, + ); + + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + await service.reActivate(mockedMultiselectQuestion.id, user), + ).toEqual({ + success: true, + }); - expect(prisma.multiselectQuestions.findFirst).toHaveBeenCalledWith({ + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ include: { jurisdiction: true, + multiselectOptions: true, }, where: { - id: 'example Id', + id: mockedMultiselectQuestion.id, + }, + }); + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.active, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + }); + + it('should error with an invalid transition', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + MultiselectQuestionsStatusEnum.active, + ); + + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + async () => + await service.reActivate(mockedMultiselectQuestion.id, user), + ).rejects.toThrowError(); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + multiselectOptions: true, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + expect(prisma.multiselectQuestions.update).not.toHaveBeenCalled(); + }); + }); + + describe('retire', () => { + it('should update status to retired for a multiselectQuestion with closed listings', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 1, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + MultiselectQuestionsStatusEnum.active, + ); + + prisma.multiselectQuestions.findUnique = jest.fn().mockResolvedValue({ + ...mockedMultiselectQuestion, + listings: [{ listings: { status: ListingsStatusEnum.closed } }], + }); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect(await service.retire(mockedMultiselectQuestion.id, user)).toEqual({ + success: true, + }); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + listings: { + include: { + listings: { + select: { + status: true, + }, + }, + }, + }, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.retired, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + }); + + it('should update status to toRetire for a multiselectQuestion with active listings', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 1, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + MultiselectQuestionsStatusEnum.active, + ); + + prisma.multiselectQuestions.findUnique = jest.fn().mockResolvedValue({ + ...mockedMultiselectQuestion, + listings: [ + { listings: { status: ListingsStatusEnum.closed } }, + { listings: { status: ListingsStatusEnum.active } }, + ], + }); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect(await service.retire(mockedMultiselectQuestion.id, user)).toEqual({ + success: true, + }); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + listings: { + include: { + listings: { + select: { + status: true, + }, + }, + }, + }, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + }); + + it('should error with an invalid transition', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + + prisma.multiselectQuestions.findUnique = jest.fn().mockResolvedValue({ + ...mockedMultiselectQuestion, + listings: [{ listings: { status: ListingsStatusEnum.closed } }], + }); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + async () => await service.retire(mockedMultiselectQuestion.id, user), + ).rejects.toThrowError(); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + listings: { + include: { + listings: { + select: { + status: true, + }, + }, + }, + }, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + expect(prisma.multiselectQuestions.update).not.toHaveBeenCalled(); + }); + }); + + describe('retireMultiselectQuestions', () => { + it('should call updateMany', async () => { + prisma.cronJob.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + prisma.cronJob.update = jest.fn().mockResolvedValue(true); + prisma.multiselectQuestions.updateMany = jest + .fn() + .mockResolvedValue({ count: 2 }); + + expect(await service.retireMultiselectQuestions()).toEqual({ + success: true, + }); + + expect(prisma.cronJob.findFirst).toHaveBeenCalled(); + expect(prisma.cronJob.update).toHaveBeenCalled(); + expect(prisma.multiselectQuestions.updateMany).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.retired, + }, + where: { + listings: { + every: { + listings: { + status: ListingsStatusEnum.closed, + }, + }, + }, + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }); + }); + }); + + describe('markCronJobAsStarted', () => { + it('should create new cronjob entry if none is present', async () => { + prisma.cronJob.findFirst = jest.fn().mockResolvedValue(null); + prisma.cronJob.create = jest.fn().mockResolvedValue(true); + + await service.markCronJobAsStarted('MSQ_RETIRE_CRON_JOB'); + + expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({ + where: { + name: 'MSQ_RETIRE_CRON_JOB', + }, + }); + expect(prisma.cronJob.create).toHaveBeenCalledWith({ + data: { + lastRunDate: expect.anything(), + name: 'MSQ_RETIRE_CRON_JOB', + }, + }); + }); + + it('should update cronjob entry if one is present', async () => { + prisma.cronJob.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + prisma.cronJob.update = jest.fn().mockResolvedValue(true); + + await service.markCronJobAsStarted('MSQ_RETIRE_CRON_JOB'); + + expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({ + where: { + name: 'MSQ_RETIRE_CRON_JOB', + }, + }); + expect(prisma.cronJob.update).toHaveBeenCalledWith({ + data: { + lastRunDate: expect.anything(), + }, + where: { + id: expect.anything(), }, }); }); diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 0cf2324d13..b1fdc5bfe6 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1311,6 +1311,28 @@ export class MultiselectQuestionsService { axios(configs, resolve, reject) }) } + /** + * Update multiselect question + */ + update( + params: { + /** requestBody */ + body?: MultiselectQuestionUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/multiselectQuestions" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } /** * Delete multiselect question by id */ @@ -1334,38 +1356,39 @@ export class MultiselectQuestionsService { }) } /** - * Get multiselect question by id + * Re-activate a multiselect question */ - retrieve( + reActivate( params: { - /** */ - multiselectQuestionId: string + /** requestBody */ + body?: IdDTO } = {} as any, options: IRequestOptions = {} - ): Promise { + ): Promise { return new Promise((resolve, reject) => { - let url = basePath + "/multiselectQuestions/{multiselectQuestionId}" - url = url.replace("{multiselectQuestionId}", params["multiselectQuestionId"] + "") + let url = basePath + "/multiselectQuestions/reActivate" - const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) - /** 适配ios13,get请求不允许带body */ + let data = params.body + + configs.data = data axios(configs, resolve, reject) }) } /** - * Update multiselect question + * Retire a multiselect question */ - update( + retire( params: { /** requestBody */ - body?: MultiselectQuestionUpdate + body?: IdDTO } = {} as any, options: IRequestOptions = {} - ): Promise { + ): Promise { return new Promise((resolve, reject) => { - let url = basePath + "/multiselectQuestions/{multiselectQuestionId}" + let url = basePath + "/multiselectQuestions/retire" const configs: IRequestConfig = getConfigs("put", "application/json", url, options) @@ -1373,6 +1396,43 @@ export class MultiselectQuestionsService { configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Trigger the retirement of multiselect questions cron job + */ + retireMultiselectQuestions(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/multiselectQuestions/retireMultiselectQuestions" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = null + + configs.data = data + + axios(configs, resolve, reject) + }) + } + /** + * Get multiselect question by id + */ + retrieve( + params: { + /** */ + multiselectQuestionId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/multiselectQuestions/{multiselectQuestionId}" + url = url.replace("{multiselectQuestionId}", params["multiselectQuestionId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + /** 适配ios13,get请求不允许带body */ + axios(configs, resolve, reject) }) } @@ -3050,6 +3110,15 @@ export interface MultiselectLink { } export interface MultiselectOption { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + /** */ collectAddress?: boolean @@ -3101,6 +3170,9 @@ export interface MultiselectOption { /** */ text: string + /** */ + untranslatedName?: string + /** */ untranslatedText?: string @@ -3160,6 +3232,9 @@ export interface MultiselectQuestion { /** */ text: string + /** */ + untranslatedName?: string + /** */ untranslatedText?: string @@ -6160,6 +6235,62 @@ export interface Jurisdiction { visibleNeighborhoodAmenities: NeighborhoodAmenitiesEnum[] } +export interface MultiselectOptionCreate { + /** */ + collectAddress?: boolean + + /** */ + collectName?: boolean + + /** */ + collectRelationship?: boolean + + /** */ + description?: string + + /** */ + exclusive?: boolean + + /** */ + isOptOut?: boolean + + /** */ + links?: MultiselectLink[] + + /** */ + mapLayerId?: string + + /** */ + mapPinPosition?: string + + /** */ + multiselectQuestion?: IdDTO + + /** */ + name?: string + + /** */ + ordinal: number + + /** */ + radiusSize?: number + + /** */ + shouldCollectAddress?: boolean + + /** */ + shouldCollectName?: boolean + + /** */ + shouldCollectRelationship?: boolean + + /** */ + text: string + + /** */ + validationMethod?: ValidationMethodEnum +} + export interface MultiselectQuestionCreate { /** */ applicationSection: MultiselectQuestionsApplicationSectionEnum @@ -6182,17 +6313,14 @@ export interface MultiselectQuestionCreate { /** */ links?: MultiselectLink[] - /** */ - multiselectOptions?: MultiselectOption[] - /** */ name?: string /** */ - options?: MultiselectOption[] + optOutText?: string /** */ - optOutText?: string + status: MultiselectQuestionsStatusEnum /** */ subText?: string @@ -6202,6 +6330,71 @@ export interface MultiselectQuestionCreate { /** */ untranslatedOptOutText?: string + + /** */ + multiselectOptions?: MultiselectOptionCreate[] + + /** */ + options?: MultiselectOptionCreate[] +} + +export interface MultiselectOptionUpdate { + /** */ + collectAddress?: boolean + + /** */ + collectName?: boolean + + /** */ + collectRelationship?: boolean + + /** */ + description?: string + + /** */ + exclusive?: boolean + + /** */ + isOptOut?: boolean + + /** */ + links?: MultiselectLink[] + + /** */ + mapLayerId?: string + + /** */ + mapPinPosition?: string + + /** */ + multiselectQuestion?: IdDTO + + /** */ + name?: string + + /** */ + ordinal: number + + /** */ + radiusSize?: number + + /** */ + shouldCollectAddress?: boolean + + /** */ + shouldCollectName?: boolean + + /** */ + shouldCollectRelationship?: boolean + + /** */ + text: string + + /** */ + validationMethod?: ValidationMethodEnum + + /** */ + id?: string } export interface MultiselectQuestionUpdate { @@ -6229,17 +6422,14 @@ export interface MultiselectQuestionUpdate { /** */ links?: MultiselectLink[] - /** */ - multiselectOptions?: MultiselectOption[] - /** */ name?: string /** */ - options?: MultiselectOption[] + optOutText?: string /** */ - optOutText?: string + status: MultiselectQuestionsStatusEnum /** */ subText?: string @@ -6249,6 +6439,12 @@ export interface MultiselectQuestionUpdate { /** */ untranslatedOptOutText?: string + + /** */ + multiselectOptions?: MultiselectOptionUpdate[] + + /** */ + options?: MultiselectOptionUpdate[] } export interface MultiselectQuestionQueryParams { diff --git a/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx b/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx index 33a68360b6..13b40e0efb 100644 --- a/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx +++ b/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx @@ -231,6 +231,9 @@ const FormMultiselectQuestions = ({ collectAddress: false, exclusive: true, ordinal: question.options.length, + id: null, + createdAt: null, + updatedAt: null, }, question )} @@ -251,6 +254,9 @@ const FormMultiselectQuestions = ({ collectAddress: false, exclusive: true, ordinal: question.options.length, + id: null, + createdAt: null, + updatedAt: null, }, question, setValue diff --git a/sites/partners/src/components/settings/PreferenceDrawer.tsx b/sites/partners/src/components/settings/PreferenceDrawer.tsx index f206a2b988..0f667a53ac 100644 --- a/sites/partners/src/components/settings/PreferenceDrawer.tsx +++ b/sites/partners/src/components/settings/PreferenceDrawer.tsx @@ -13,9 +13,11 @@ import { AuthContext } from "@bloom-housing/shared-helpers" import { useForm } from "react-hook-form" import { MultiselectOption, + MultiselectOptionCreate, MultiselectQuestion, MultiselectQuestionCreate, MultiselectQuestionsApplicationSectionEnum, + MultiselectQuestionsStatusEnum, MultiselectQuestionUpdate, ValidationMethodEnum, YesNoEnum, @@ -493,9 +495,14 @@ const PreferenceDrawer = ({ const formattedQuestionData: MultiselectQuestionUpdate | MultiselectQuestionCreate = { applicationSection: MultiselectQuestionsApplicationSectionEnum.preferences, - text: formValues.text, description: formValues.description, hideFromListing: formValues.showOnListingQuestion === YesNoEnum.no, + jurisdictions: [ + profile.jurisdictions.find((juris) => juris.id === formValues.jurisdictionId), + ], + links: formValues.preferenceUrl + ? [{ title: formValues.preferenceLinkTitle, url: formValues.preferenceUrl }] + : [], optOutText: optOutQuestion === YesNoEnum.yes && formValues.optOutText && @@ -503,12 +510,8 @@ const PreferenceDrawer = ({ ? formValues.optOutText : null, options: questionData?.options, - jurisdictions: [ - profile.jurisdictions.find((juris) => juris.id === formValues.jurisdictionId), - ], - links: formValues.preferenceUrl - ? [{ title: formValues.preferenceLinkTitle, url: formValues.preferenceUrl }] - : [], + status: questionData?.status ?? MultiselectQuestionsStatusEnum.draft, + text: formValues.text, } clearErrors() clearErrors("questions") @@ -889,7 +892,7 @@ const PreferenceDrawer = ({ return questionData?.options?.length ? questionData?.options.length + 1 : 1 } - const newOptionData: MultiselectOption = { + const newOptionData: MultiselectOptionCreate = { text: formData.optionTitle, description: formData.optionDescription, links: formData.optionUrl diff --git a/sites/public/src/components/applications/ApplicationMultiselectQuestionStep.tsx b/sites/public/src/components/applications/ApplicationMultiselectQuestionStep.tsx index d836770614..d08def9eb9 100644 --- a/sites/public/src/components/applications/ApplicationMultiselectQuestionStep.tsx +++ b/sites/public/src/components/applications/ApplicationMultiselectQuestionStep.tsx @@ -214,6 +214,9 @@ const ApplicationMultiselectQuestionStep = ({ collectAddress: false, exclusive: true, ordinal: question.options.length + 1, + id: null, + createdAt: null, + updatedAt: null, }) } From d784a68f40d4f09aee640981d623d5ee8eabdbed Mon Sep 17 00:00:00 2001 From: Avritt Rohwer Date: Wed, 19 Nov 2025 10:57:53 -0800 Subject: [PATCH 04/72] feat: build and publish dbseed container image (#5602) --- .github/workflows/docker_image_build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docker_image_build.yml b/.github/workflows/docker_image_build.yml index 664981e691..2e8b8a6921 100644 --- a/.github/workflows/docker_image_build.yml +++ b/.github/workflows/docker_image_build.yml @@ -26,6 +26,9 @@ jobs: - container: api docker_context: "{{defaultContext}}:api" dockerfile: Dockerfile + - container: dbseed + docker_context: "{{defaultContext}}:api" + dockerfile: Dockerfile.dbseed - container: partners docker_context: "{{defaultContext}}" dockerfile: Dockerfile.sites.partners From 263fff963e567313c7e185eb12b4f972aade329e Mon Sep 17 00:00:00 2001 From: Jared White Date: Wed, 19 Nov 2025 13:05:13 -0800 Subject: [PATCH 05/72] fix: use conductor values for Common App form step checkpoints (#5504) * feat: use conductor values for form step checkpoints * test: rework redirection tests to use conductor * test: fix inconsistent setups --- .../applications/review/redirection.test.tsx | 18 ++++++++++-------- sites/public/src/lib/hooks.ts | 7 ++----- .../src/pages/applications/start/autofill.tsx | 2 +- .../applications/start/what-to-expect.tsx | 2 +- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/sites/public/__tests__/pages/applications/review/redirection.test.tsx b/sites/public/__tests__/pages/applications/review/redirection.test.tsx index 9c76410431..8a664fb420 100644 --- a/sites/public/__tests__/pages/applications/review/redirection.test.tsx +++ b/sites/public/__tests__/pages/applications/review/redirection.test.tsx @@ -13,6 +13,8 @@ window.scrollTo = jest.fn() const server = setupServer() +const mockApplication = JSON.parse(JSON.stringify(blankApplication)) + beforeAll(() => { server.listen() }) @@ -31,12 +33,12 @@ describe("applications pages", () => { describe("back button to ada step", () => { it("no listing available should redirect to listings page", async () => { const { pushMock } = mockNextRouter() - const conductor = new ApplicationConductor({}, {}) + const conductor = new ApplicationConductor(mockApplication, null) render( { return @@ -57,12 +59,12 @@ describe("applications pages", () => { it("listing available should not redirect to listings page", async () => { const { pushMock } = mockNextRouter() - const conductor = new ApplicationConductor({}, {}) + const conductor = new ApplicationConductor(mockApplication, {}) render( { return @@ -85,12 +87,12 @@ describe("applications pages", () => { describe("back button to summary step", () => { it("no listing available should redirect to listings page", async () => { const { pushMock } = mockNextRouter() - const conductor = new ApplicationConductor({}, {}) + const conductor = new ApplicationConductor(mockApplication, null) render( { return @@ -111,12 +113,12 @@ describe("applications pages", () => { it("listing available should not redirect to listings page", async () => { const { pushMock } = mockNextRouter() - const conductor = new ApplicationConductor({}, {}) + const conductor = new ApplicationConductor(mockApplication, {}) render( { return diff --git a/sites/public/src/lib/hooks.ts b/sites/public/src/lib/hooks.ts index 45ac8cd8fe..ce4326071c 100644 --- a/sites/public/src/lib/hooks.ts +++ b/sites/public/src/lib/hooks.ts @@ -84,14 +84,11 @@ export const useRedirectToPrevPage = (defaultPath = "/") => { * @param bypassCheckpoint true if it should bypass checking that listing & application is in progress * @returns */ -export const useFormConductor = (stepName: string, bypassCheckpoint?: boolean) => { +export const useFormConductor = (stepName: string) => { useRequireLoggedInUser("/", !process.env.showMandatedAccounts) const context = useContext(AppSubmissionContext) - if (!bypassCheckpoint) { - // eslint-disable-next-line react-hooks/rules-of-hooks - useAuthenticApplicationCheckpoint(context.listing, context.application) - } const conductor = context.conductor + useAuthenticApplicationCheckpoint(conductor.listing, conductor.application) conductor.stepTo(stepName) diff --git a/sites/public/src/pages/applications/start/autofill.tsx b/sites/public/src/pages/applications/start/autofill.tsx index c73a5c27e8..1068b3293c 100644 --- a/sites/public/src/pages/applications/start/autofill.tsx +++ b/sites/public/src/pages/applications/start/autofill.tsx @@ -27,7 +27,7 @@ import { CardSection } from "@bloom-housing/ui-seeds/src/blocks/Card" const Autofill = () => { const router = useRouter() - const context = useFormConductor("autofill", true) + const context = useFormConductor("autofill") const { conductor, application, listing } = context const { initialStateLoaded, profile, applicationsService } = useContext(AuthContext) const [submitted, setSubmitted] = useState(false) diff --git a/sites/public/src/pages/applications/start/what-to-expect.tsx b/sites/public/src/pages/applications/start/what-to-expect.tsx index 4aaa690fe6..5bb8cdd1d6 100644 --- a/sites/public/src/pages/applications/start/what-to-expect.tsx +++ b/sites/public/src/pages/applications/start/what-to-expect.tsx @@ -16,7 +16,7 @@ import { isUnitGroupAppBase, isUnitGroupAppWaitlist } from "../../../lib/helpers const ApplicationWhatToExpect = () => { const { profile } = useContext(AuthContext) - const { conductor, listing } = useFormConductor("whatToExpect", true) + const { conductor, listing } = useFormConductor("whatToExpect") const router = useRouter() const { handleSubmit } = useForm() From 27d8f00f9b8479b9873587e83ab73dafbfa94c70 Mon Sep 17 00:00:00 2001 From: Mateusz Zduniuk <137785676+matzduniuk@users.noreply.github.com> Date: Thu, 20 Nov 2025 09:04:42 +0100 Subject: [PATCH 06/72] feat: non-regulated fields implementation (#5455) * fix: fix schema formatting * chore: add new enum types * chore: add new Listings model fields * chore: add new fields to the UnitGroup model * chore: update DTO's to changes in schema * chore: generate new swagger types * chore: add migration for new schema updates * fix: update depositValue field decimal range * fix: add missing field names * chore: add custom deposit validation decorator * chore: add unit group rent type validation * fix: remove unnecessary import * chore: update sunit summary DTO to contain new fields * chore: update service layer * fix: remove unnecessary migration update * fix: update db fields * chore: update rent validation decorator * chore: add the cocInfo to the listing DTO * chore: regenerate backend swagger * chore: add listing type selector * chore: add unit form non regulated rent fields * fix: fix invalid logic * chore: update prop to and optional value * chore: add rent type fields for unit group form * chore: hide additional fields for non regulated listings * chore: add fields for the deposit types * chore: update labels * chore: update community types fields * chore: add listing ebll clearance field * chore: update built year filed rendering logic * chore: fix field decorator error * fix: fix decorators import paths * chore: update required documents form fields * chore: update the schema to include the new required documents list table * fix: remove unused import * chore: update listings factory to include the new required documents field * chore: hide non regulated switch field based on feature flag * chore: retain the original required documents field * Revert "chore: hide non regulated switch field based on feature flag" This reverts commit 11f087453e22e28185e5dab9271753544a41631f. * fix: add missing fields validation * fix: update imports * fix: remove the package manager filed in package JSON * fix: update the hasHudEbllClearance field * fix: remove unused import * chore: update the label to match the current listing type * fix: fix the application fee full width field * fix: hide the new deposit and rent fields for regulated units * fix: boolean ebll formatting for form submission * chore: add non regulated listing fields to intro and fees sections * chore: add missing documents model column mapping * fix: udpdate form subssion for the new required documents field * chore: add required documents preview in the details * fix: add feature flag based regulated fields control * chore: update the validation decorator * chore: update listing details page to new jest standard * fix: fix failing backend API tests * fix: fix backend integration tests * fix: fix decorator logic * fix: add unit group rent range revalidation triggering * fix: fix unwanted initial ebll field reset * fix: required documents preview filtering * fix: update the developer field title switching on listing preview page * fix: hide new fields on listing preview based on the non-regulated feature flag * fix: fix label typo * fix: update new required documents field label formatting * fix: hide the new documents field group on regulated listings * fix: remove unused import * fix: add missing monthly rent field to database * fix: fix unit groups integration tests * fix: paper listing form failing tests * chore: rename migration files * fix: clean-up merge conflict resolve * fix: remove unnecessary listing service code * fix: hide the new required documents field from preview page based on feature flag * fix: remove unused prop * fix: update the listings factory to add new required documents only for non regulated mocks * chore: add logic to disconnect and delete documents table entry automatically * fix: fix Units component feature flag detection code * chore: add integration tests for non regulated listing preview * chore: add integration tests for the ListingIntro section * chore: add integration tests for AdditionalDetails form section component * chore: add deposit type default field for the listing form * chore: add integration tests for the AdditionalFees listing form component * chore: add integration tests for the unit group form for non-regulated listing * fix: update the listing service to remove the documents entry after completed transaction * fix: revert the CommunityType component logic * chore: partially revert additional fees section logic * fix: update the listingType field clearing to undefined * fix: remove unused imports * fix: revert changes for unit form * test: re-run testing suite * chore: remove the depositRangeMin and depositRangeMax fields * chore: update the data formatter to handle different deposit type values * fix: update api integration tests to new changes * fix: fix csv export integration tests * test: re-run testing suite * fix: update partners site integration tests * test: re-run testing suite * fix: remove unnecessary console log * fix: clear deposit on save and continue (#5598) --------- Co-authored-by: Morgan Ludtke <42942267+ludtkemorgan@users.noreply.github.com> --- .../migration.sql | 2 - .../migration.sql | 26 + .../migration.sql | 4 + api/prisma/schema.prisma | 27 +- api/prisma/seed-helpers/listing-factory.ts | 38 + .../validate-listing-deposit.decorator.ts | 28 +- .../validate-unit-groups-rent.decorator.ts | 2 +- .../validate-units-rent.decorator.ts | 58 + .../dtos/listings/listing-documents.dto.ts | 51 + api/src/dtos/listings/listing.dto.ts | 26 +- api/src/dtos/unit-groups/unit-group.dto.ts | 5 + api/src/dtos/units/units-summary.dto.ts | 5 + .../services/listing-csv-export.service.ts | 18 +- api/src/services/listing.service.ts | 40 + .../utilities/application-export-helpers.ts | 2 +- .../application-flagged-set.e2e-spec.ts | 2 +- api/test/integration/listing.e2e-spec.ts | 84 +- .../listing-csv-export.service.spec.ts | 4 +- .../unit/services/listing.service.spec.ts | 12 +- shared-helpers/src/locales/ar.json | 9 + shared-helpers/src/locales/bn.json | 9 + shared-helpers/src/locales/general.json | 10 + shared-helpers/src/locales/tl.json | 9 + shared-helpers/src/locales/zh.json | 9 + shared-helpers/src/types/backend-swagger.ts | 73 +- shared-helpers/src/utilities/formKeys.ts | 12 + .../applications/sections/helpers.tsx | 12 +- .../PaperListingForm/UnitGroupForm.test.tsx | 107 +- .../listings/PaperListingForm/index.test.tsx | 4 - .../sections/AdditionalDetails.test.tsx | 180 +++ .../sections/AdditionalFees.test.tsx | 187 +++ .../sections/ListingIntro.test.tsx | 100 +- .../pages/listings/[id]/index.test.tsx | 1373 ++++++++++------- .../locale_overrides/general.json | 19 +- .../sections/DetailAdditionalDetails.tsx | 51 +- .../sections/DetailAdditionalFees.tsx | 63 +- .../sections/DetailListingIntro.tsx | 49 +- .../PaperListingForm/UnitGroupForm.tsx | 328 ++-- .../listings/PaperListingForm/index.tsx | 13 +- .../sections/AdditionalDetails.tsx | 62 +- .../sections/AdditionalFees.tsx | 160 +- .../sections/BuildingDetails.tsx | 72 +- .../sections/ListingIntro.tsx | 93 +- .../PaperListingForm/sections/Units.tsx | 14 + .../listings/AdditionalMetadataFormatter.ts | 37 +- .../src/lib/listings/BooleansFormatter.ts | 4 + .../src/lib/listings/UnitGroupsFormatter.ts | 3 + sites/partners/src/lib/listings/formTypes.ts | 4 + 48 files changed, 2594 insertions(+), 906 deletions(-) create mode 100644 api/prisma/migrations/37_add_required_documents_list_model/migration.sql create mode 100644 api/prisma/migrations/38_add_monthly_rent_to_unit_group/migration.sql create mode 100644 api/src/decorators/validate-units-rent.decorator.ts create mode 100644 api/src/dtos/listings/listing-documents.dto.ts create mode 100644 sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalDetails.test.tsx create mode 100644 sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalFees.test.tsx diff --git a/api/prisma/migrations/32_add_non_regulated_listings_fields/migration.sql b/api/prisma/migrations/32_add_non_regulated_listings_fields/migration.sql index d7e306e10e..bbeaec3e7f 100644 --- a/api/prisma/migrations/32_add_non_regulated_listings_fields/migration.sql +++ b/api/prisma/migrations/32_add_non_regulated_listings_fields/migration.sql @@ -10,8 +10,6 @@ CREATE TYPE "rent_type_enum" AS ENUM ('fixedRent', 'rentRange'); -- AlterTable ALTER TABLE "listings" ADD COLUMN "coc_info" TEXT, -ADD COLUMN "deposit_range_max" INTEGER, -ADD COLUMN "deposit_range_min" INTEGER, ADD COLUMN "deposit_type" "deposit_type_enum", ADD COLUMN "deposit_value" DECIMAL(65,30), ADD COLUMN "has_hud_ebll_clearance" BOOLEAN, diff --git a/api/prisma/migrations/37_add_required_documents_list_model/migration.sql b/api/prisma/migrations/37_add_required_documents_list_model/migration.sql new file mode 100644 index 0000000000..4d8792b5ce --- /dev/null +++ b/api/prisma/migrations/37_add_required_documents_list_model/migration.sql @@ -0,0 +1,26 @@ +-- AlterTable +ALTER TABLE "listings" ADD COLUMN "documents_id" UUID; + +-- CreateTable +CREATE TABLE "listing_documents" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL, + "birth_certificate" BOOLEAN, + "current_landlord_reference" BOOLEAN, + "government_issued_id" BOOLEAN, + "previous_landlord_reference" BOOLEAN, + "proof_of_assets" BOOLEAN, + "proof_of_custody" BOOLEAN, + "proof_of_income" BOOLEAN, + "residency_documents" BOOLEAN, + "social_security_card" BOOLEAN, + + CONSTRAINT "listing_documents_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "listings_documents_id_key" ON "listings"("documents_id"); + +-- AddForeignKey +ALTER TABLE "listings" ADD CONSTRAINT "listings_documents_id_fkey" FOREIGN KEY ("documents_id") REFERENCES "listing_documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/api/prisma/migrations/38_add_monthly_rent_to_unit_group/migration.sql b/api/prisma/migrations/38_add_monthly_rent_to_unit_group/migration.sql new file mode 100644 index 0000000000..01fcf4cce1 --- /dev/null +++ b/api/prisma/migrations/38_add_monthly_rent_to_unit_group/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "unit_group" +ADD COLUMN "monthly_rent" DECIMAL, +ALTER COLUMN "flat_rent_value_from" SET DATA TYPE DECIMAL, +ALTER COLUMN "flat_rent_value_to" SET DATA TYPE DECIMAL; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 71f75363f2..72b53731a0 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -527,6 +527,24 @@ model ListingUtilities { @@map("listing_utilities") } +model ListingDocuments { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + socialSecurityCard Boolean? @map("social_security_card") + currentLandlordReference Boolean? @map("current_landlord_reference") + birthCertificate Boolean? @map("birth_certificate") + previousLandlordReference Boolean? @map("previous_landlord_reference") + governmentIssuedId Boolean? @map("government_issued_id") + proofOfAssets Boolean? @map("proof_of_assets") + proofOfIncome Boolean? @map("proof_of_income") + residencyDocuments Boolean? @map("residency_documents") + proofOfCustody Boolean? @map("proof_of_custody") + listings Listings? + + @@map("listing_documents") +} + enum ListingTypeEnum { regulated nonRegulated @@ -594,8 +612,6 @@ model Listings { depositMax String? @map("deposit_max") depositType DepositTypeEnum? @map("deposit_type") depositValue Decimal? @map("deposit_value") @db.Decimal(8, 2) - depositRangeMin Int? @map("deposit_range_min") - depositRangeMax Int? @map("deposit_range_max") depositHelperText String? @map("deposit_helper_text") disableUnitsAccordion Boolean? @map("disable_units_accordion") hasHudEbllClearance Boolean? @map("has_hud_ebll_clearance") @@ -611,6 +627,7 @@ model Listings { rentalAssistance String? @map("rental_assistance") rentalHistory String? @map("rental_history") requiredDocuments String? @map("required_documents") + requiredDocumentsList ListingDocuments? @relation(fields: [documentsId], references: [id], onDelete: NoAction, onUpdate: NoAction) specialNotes String? @map("special_notes") waitlistCurrentSize Int? @map("waitlist_current_size") waitlistMaxSize Int? @map("waitlist_max_size") @@ -644,6 +661,7 @@ model Listings { resultId String? @map("result_id") @db.Uuid featuresId String? @unique() @map("features_id") @db.Uuid utilitiesId String? @unique() @map("utilities_id") @db.Uuid + documentsId String? @unique() @map("documents_id") @db.Uuid includeCommunityDisclaimer Boolean? @map("include_community_disclaimer") communityDisclaimerTitle String? @map("community_disclaimer_title") communityDisclaimerDescription String? @map("community_disclaimer_description") @@ -1016,8 +1034,9 @@ model UnitGroup { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamp(6) maxOccupancy Int? @map("max_occupancy") minOccupancy Int? @map("min_occupancy") - flatRentValueFrom Decimal? @map("flat_rent_value_from") - flatRentValueTo Decimal? @map("flat_rent_value_to") + flatRentValueFrom Decimal? @map("flat_rent_value_from") @db.Decimal + flatRentValueTo Decimal? @map("flat_rent_value_to") @db.Decimal + monthlyRent Decimal? @map("monthly_rent") @db.Decimal floorMin Int? @map("floor_min") floorMax Int? @map("floor_max") totalCount Int? @map("total_count") diff --git a/api/prisma/seed-helpers/listing-factory.ts b/api/prisma/seed-helpers/listing-factory.ts index 1d192213de..9f501eec01 100644 --- a/api/prisma/seed-helpers/listing-factory.ts +++ b/api/prisma/seed-helpers/listing-factory.ts @@ -8,6 +8,7 @@ import { PrismaClient, ReservedCommunityTypes, ReviewOrderTypeEnum, + ListingTypeEnum, } from '@prisma/client'; import { randomInt } from 'crypto'; import dayjs from 'dayjs'; @@ -51,6 +52,18 @@ type optionalFeatures = { loweredCabinets?: boolean; }; +type requiredDocuments = { + socialSecurityCard?: boolean; + currentLandlordReference?: boolean; + birthCertificate?: boolean; + previousLandlordReference?: boolean; + governmentIssuedId?: boolean; + proofOfAssets?: boolean; + proofOfIncome?: boolean; + residencyDocuments?: boolean; + proofOfCustody?: boolean; +}; + type optionalUtilities = { water?: boolean; gas?: boolean; @@ -89,6 +102,7 @@ export const listingFactory = async ( unitGroups?: Prisma.UnitGroupCreateWithoutListingsInput[]; units?: Prisma.UnitsCreateWithoutListingsInput[]; userAccounts?: Prisma.UserAccountsWhereUniqueInput[]; + requiredDocumentsList?: requiredDocuments; }, ): Promise => { const previousListing = optionalParams?.listing || {}; @@ -260,6 +274,9 @@ export const listingFactory = async ( optionalParams?.optionalFeatures, optionalParams?.optionalUtilities, ), + ...(optionalParams?.listing?.listingType === ListingTypeEnum.nonRegulated + ? listingsRequiredDocuments(optionalParams?.requiredDocumentsList) + : {}), ...previousListing, }; }; @@ -289,6 +306,27 @@ const buildingFeatures = (includeBuildingFeatures: boolean) => { }; }; +export const listingsRequiredDocuments = ( + requiredDocumentsList?: requiredDocuments, +): { + requiredDocumentsList; +} => ({ + requiredDocumentsList: { + create: { + socialSecurityCard: randomBoolean(), + currentLandlordReference: randomBoolean(), + birthCertificate: randomBoolean(), + previousLandlordReference: randomBoolean(), + governmentIssuedId: randomBoolean(), + proofOfAssets: randomBoolean(), + proofOfIncome: randomBoolean(), + residencyDocuments: randomBoolean(), + proofOfCustody: randomBoolean(), + ...requiredDocumentsList, + }, + }, +}); + export const featuresAndUtilites = ( optionalFeatures?: optionalFeatures, optionalUtilities?: optionalUtilities, diff --git a/api/src/decorators/validate-listing-deposit.decorator.ts b/api/src/decorators/validate-listing-deposit.decorator.ts index 71c5d058b7..c009bf9225 100644 --- a/api/src/decorators/validate-listing-deposit.decorator.ts +++ b/api/src/decorators/validate-listing-deposit.decorator.ts @@ -7,7 +7,7 @@ import { ValidatorOptions, } from 'class-validator'; import Listing from '../dtos/listings/listing.dto'; -import { DepositTypeEnum } from '@prisma/client'; +import { DepositTypeEnum, ListingTypeEnum } from '@prisma/client'; export function ValidateListingDeposit(validationOptions?: ValidatorOptions) { return function (object: object, propertyName: string) { @@ -28,28 +28,32 @@ export class DepositValueConstraint implements ValidatorConstraintInterface { } validate(value: DepositTypeEnum, args: ValidationArguments) { - const { depositValue, depositRangeMin, depositRangeMax } = + const { depositValue, depositMin, depositMax, listingType } = args.object as Listing; + if (!listingType || listingType === ListingTypeEnum.regulated) { + return true; + } + if (value === DepositTypeEnum.fixedDeposit) { - return ( - !this.isFieldEmpty(depositValue) && - this.isFieldEmpty(depositRangeMin) && - this.isFieldEmpty(depositRangeMax) - ); + return this.isFieldEmpty(depositMin) && this.isFieldEmpty(depositMax); + } + if (value === DepositTypeEnum.depositRange) { + return this.isFieldEmpty(depositValue); } + // If no Deposit type is selected then validate that it's either just depositValue or just the range values return ( - this.isFieldEmpty(depositValue) && - !this.isFieldEmpty(depositRangeMin) && - !this.isFieldEmpty(depositRangeMax) + this.isFieldEmpty(depositValue) || + (this.isFieldEmpty(depositMin) && this.isFieldEmpty(depositMax)) ); } defaultMessage(args?: ValidationArguments): string { const value = args.value as DepositTypeEnum; + if (value === DepositTypeEnum.fixedDeposit) { - return 'When deposit is of type "fixedDeposit" the "depositValue" must be filled and the "depositRangeMin"|"depositRangeMax" fields must be null'; + return 'When deposit is of type "fixedDeposit" the "depositValue" must be filled and the "depositMin"|"depositMax" fields must be null'; } - return 'When deposit is of type "depositRange" the "depositRangeMin" and "depositRangeMax" fields must be filled and "depositValue" must be null'; + return 'When deposit is of type "depositRange" the "depositMin" and "depositMax" fields must be filled and "depositValue" must be null'; } } diff --git a/api/src/decorators/validate-unit-groups-rent.decorator.ts b/api/src/decorators/validate-unit-groups-rent.decorator.ts index af080b5f52..71ef713e6c 100644 --- a/api/src/decorators/validate-unit-groups-rent.decorator.ts +++ b/api/src/decorators/validate-unit-groups-rent.decorator.ts @@ -7,7 +7,7 @@ import { ValidatorConstraintInterface, ValidatorOptions, } from 'class-validator'; -import UnitGroup from 'src/dtos/unit-groups/unit-group.dto'; +import UnitGroup from '../dtos/unit-groups/unit-group.dto'; export function ValidateUnitGroupRent(validationOptions?: ValidatorOptions) { return function (object: object, propertyName: string) { diff --git a/api/src/decorators/validate-units-rent.decorator.ts b/api/src/decorators/validate-units-rent.decorator.ts new file mode 100644 index 0000000000..a93cc22ca0 --- /dev/null +++ b/api/src/decorators/validate-units-rent.decorator.ts @@ -0,0 +1,58 @@ +import { RentTypeEnum } from '@prisma/client'; +import { + registerDecorator, + ValidationArguments, + ValidationTypes, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidatorOptions, +} from 'class-validator'; +import UnitGroup from '../dtos/unit-groups/unit-group.dto'; + +export function ValidateUnitGroupRent(validationOptions?: ValidatorOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: ValidationTypes.CUSTOM_VALIDATION, + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: RentValueConstraint, + }); + }; +} + +@ValidatorConstraint() +export class RentValueConstraint implements ValidatorConstraintInterface { + isFieldEmpty(value): boolean { + return value === null || value === undefined; + } + + validate( + value: RentTypeEnum, + validationArguments?: ValidationArguments, + ): Promise | boolean { + const { flatRentValueFrom, flatRentValueTo } = + validationArguments.object as UnitGroup; + + if (value === RentTypeEnum.rentRange) { + return ( + !this.isFieldEmpty(flatRentValueFrom) && + !this.isFieldEmpty(flatRentValueTo) + ); + } else { + return ( + this.isFieldEmpty(flatRentValueFrom) && + this.isFieldEmpty(flatRentValueTo) + ); + } + + return true; + } + + defaultMessage(validationArguments?: ValidationArguments): string { + const rentType = validationArguments.value as RentTypeEnum; + if (rentType === RentTypeEnum.rentRange) { + return 'When rent is of type "rentRange" the "flatRentValueFrom" and "flatRentValueTo" fields must be filled'; + } + } +} diff --git a/api/src/dtos/listings/listing-documents.dto.ts b/api/src/dtos/listings/listing-documents.dto.ts new file mode 100644 index 0000000000..733a3eb120 --- /dev/null +++ b/api/src/dtos/listings/listing-documents.dto.ts @@ -0,0 +1,51 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ListingDocuments { + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + socialSecurityCard?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + currentLandlordReference?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + birthCertificate?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + previousLandlordReference?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + governmentIssuedId?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + proofOfAssets?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + proofOfIncome?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + residencyDocuments?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + proofOfCustody?: boolean; +} diff --git a/api/src/dtos/listings/listing.dto.ts b/api/src/dtos/listings/listing.dto.ts index ff4c438148..44b54cab64 100644 --- a/api/src/dtos/listings/listing.dto.ts +++ b/api/src/dtos/listings/listing.dto.ts @@ -62,6 +62,7 @@ import { ValidateOnlyUnitsOrUnitGroups, } from '../../decorators/validate-units-required.decorator'; import { ValidateListingDeposit } from '../../decorators/validate-listing-deposit.decorator'; +import { ListingDocuments } from './listing-documents.dto'; class Listing extends AbstractDTO { @Expose() @@ -398,22 +399,6 @@ class Listing extends AbstractDTO { @ApiPropertyOptional() depositValue?: number; - @Expose() - @ValidateListingPublish('depositRangeMin', { - groups: [ValidationsGroupsEnum.default], - }) - @IsNumber() - @ApiPropertyOptional() - depositRangeMin?: number; - - @Expose() - @ValidateListingPublish('depositRangeMax', { - groups: [ValidationsGroupsEnum.default], - }) - @IsNumber() - @ApiPropertyOptional() - depositRangeMax?: number; - @Expose() @ValidateListingPublish('depositHelperText', { groups: [ValidationsGroupsEnum.default], @@ -554,6 +539,15 @@ class Listing extends AbstractDTO { @ApiPropertyOptional() requiredDocuments?: string; + @Expose() + @ValidateListingPublish('requiredDocumentsList', { + groups: [ValidationsGroupsEnum.default], + }) + @Type(() => ListingDocuments) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiPropertyOptional({ type: ListingDocuments }) + requiredDocumentsList?: ListingDocuments; + @Expose() @ValidateListingPublish('specialNotes', { groups: [ValidationsGroupsEnum.default], diff --git a/api/src/dtos/unit-groups/unit-group.dto.ts b/api/src/dtos/unit-groups/unit-group.dto.ts index 9ca8ee2372..92f914a9e1 100644 --- a/api/src/dtos/unit-groups/unit-group.dto.ts +++ b/api/src/dtos/unit-groups/unit-group.dto.ts @@ -30,6 +30,11 @@ class UnitGroup extends AbstractDTO { @ApiPropertyOptional() flatRentValueTo?: number; + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + monthlyRent?: number; + @Expose() @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() diff --git a/api/src/dtos/units/units-summary.dto.ts b/api/src/dtos/units/units-summary.dto.ts index 2da9eb7817..ff675eb51d 100644 --- a/api/src/dtos/units/units-summary.dto.ts +++ b/api/src/dtos/units/units-summary.dto.ts @@ -124,6 +124,11 @@ class UnitsSummary { @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() flatRentValueTo?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + monthlyRent?: number; } export { UnitsSummary as default, UnitsSummary }; diff --git a/api/src/services/listing-csv-export.service.ts b/api/src/services/listing-csv-export.service.ts index ffb3290f3c..1a35d7ef3a 100644 --- a/api/src/services/listing-csv-export.service.ts +++ b/api/src/services/listing-csv-export.service.ts @@ -757,16 +757,6 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { path: 'depositHelperText', label: 'Deposit Helper Text', }, - { - path: 'depositMin', - label: 'Deposit Min', - format: this.formatCurrency, - }, - { - path: 'depositMax', - label: 'Deposit Max', - format: this.formatCurrency, - }, { path: 'depositType', label: 'Deposit Type', @@ -777,13 +767,13 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { format: this.formatCurrency, }, { - path: 'depositRangeMin', - label: 'Deposit Range Min', + path: 'depositMin', + label: 'Deposit Min', format: this.formatCurrency, }, { - path: 'depositRangeMax', - label: 'Deposit Range Max', + path: 'depositMax', + label: 'Deposit Max', format: this.formatCurrency, }, { diff --git a/api/src/services/listing.service.ts b/api/src/services/listing.service.ts index 12c948eba1..164eed0703 100644 --- a/api/src/services/listing.service.ts +++ b/api/src/services/listing.service.ts @@ -172,6 +172,7 @@ includeViews.full = { listingsApplicationDropOffAddress: true, listingsApplicationMailingAddress: true, requestedChangesUser: true, + requiredDocumentsList: true, units: { include: { unitAmiChartOverrides: true, @@ -1321,6 +1322,11 @@ export class ListingService implements OnModuleInit { })), } : undefined, + requiredDocumentsList: dto.requiredDocumentsList + ? { + create: { ...dto.requiredDocumentsList }, + } + : undefined, listingEvents: dto.listingEvents ? { create: dto.listingEvents.map((event) => ({ @@ -1509,6 +1515,7 @@ export class ListingService implements OnModuleInit { rentType: group.rentType, flatRentValueFrom: group.flatRentValueFrom, flatRentValueTo: group.flatRentValueTo, + monthlyRent: group.monthlyRent, totalAvailable: group.totalAvailable, totalCount: group.totalCount, unitGroupAmiLevels: { @@ -2199,6 +2206,17 @@ export class ListingService implements OnModuleInit { }, } : undefined, + requiredDocumentsList: dto.requiredDocumentsList + ? { + upsert: { + where: { + id: storedListing.requiredDocumentsList?.id, + }, + create: { ...incomingDto.requiredDocumentsList }, + update: { ...incomingDto.requiredDocumentsList }, + }, + } + : undefined, // Three options for the building selection criteria file // create new one, connect existing one, or deleted (disconnect) listingsBuildingSelectionCriteriaFile: @@ -2358,6 +2376,7 @@ export class ListingService implements OnModuleInit { rentType: group.rentType, flatRentValueFrom: group.flatRentValueFrom, flatRentValueTo: group.flatRentValueTo, + monthlyRent: group.monthlyRent, sqFeetMin: group.sqFeetMin, sqFeetMax: group.sqFeetMax, totalCount: group.totalCount, @@ -2516,6 +2535,27 @@ export class ListingService implements OnModuleInit { } } + if ( + !incomingDto.requiredDocumentsList && + storedListing.requiredDocumentsList?.id + ) { + await this.prisma.listings.update({ + data: { + requiredDocumentsList: { + disconnect: { + id: storedListing.requiredDocumentsList.id, + }, + }, + }, + where: { id: storedListing.id }, + }); + await this.prisma.listingDocuments.delete({ + where: { + id: storedListing.requiredDocumentsList.id, + }, + }); + } + await this.cachePurge( storedListing.status, incomingDto.status, diff --git a/api/src/utilities/application-export-helpers.ts b/api/src/utilities/application-export-helpers.ts index afd1154cc4..cbcdfb497d 100644 --- a/api/src/utilities/application-export-helpers.ts +++ b/api/src/utilities/application-export-helpers.ts @@ -8,7 +8,7 @@ import { MultiselectQuestion } from '../dtos/multiselect-questions/multiselect-q import { UnitType } from '../dtos/unit-types/unit-type.dto'; import { CsvHeader } from '../types/CsvExportInterface'; import { formatLocalDate } from '../utilities/format-local-date'; -import { User } from 'src/dtos/users/user.dto'; +import { User } from '../dtos/users/user.dto'; import { doAnyJurisdictionHaveFeatureFlagSet } from './feature-flag-utilities'; /** * diff --git a/api/test/integration/application-flagged-set.e2e-spec.ts b/api/test/integration/application-flagged-set.e2e-spec.ts index 2fbae998a8..53193fdfdb 100644 --- a/api/test/integration/application-flagged-set.e2e-spec.ts +++ b/api/test/integration/application-flagged-set.e2e-spec.ts @@ -17,7 +17,7 @@ import { PrismaService } from '../../src/services/prisma.service'; import { jurisdictionFactory } from '../../prisma/seed-helpers/jurisdiction-factory'; import { listingFactory } from '../../prisma/seed-helpers/listing-factory'; import { applicationFactory } from '../../prisma/seed-helpers/application-factory'; -import { AfsQueryParams } from 'src/dtos/application-flagged-sets/afs-query-params.dto'; +import { AfsQueryParams } from '../../src/dtos/application-flagged-sets/afs-query-params.dto'; import { View } from '../../src/enums/application-flagged-sets/view'; import { AfsResolve } from '../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { IdDTO } from '../../src/dtos/shared/id.dto'; diff --git a/api/test/integration/listing.e2e-spec.ts b/api/test/integration/listing.e2e-spec.ts index 112db780bc..13af69c3e7 100644 --- a/api/test/integration/listing.e2e-spec.ts +++ b/api/test/integration/listing.e2e-spec.ts @@ -8,6 +8,7 @@ import { LanguagesEnum, ListingEventsTypeEnum, ListingsStatusEnum, + ListingTypeEnum, MarketingTypeEnum, MultiselectQuestionsApplicationSectionEnum, Prisma, @@ -2231,7 +2232,7 @@ describe('Listing Controller Tests', () => { }); describe('listing deposit type validation', () => { - it("should create listing when deposit is 'fixedDeposit', 'depositValue' is set and 'depositRangeMin' and 'depositRangeMax' are missing", async () => { + it("should create listing when deposit is 'fixedDeposit', and 'depositMin' and 'depositMax' are missing", async () => { const val = await constructFullListingData( undefined, undefined, @@ -2243,7 +2244,10 @@ describe('Listing Controller Tests', () => { .set({ passkey: process.env.API_PASS_KEY || '' }) .send({ ...val, + listingType: ListingTypeEnum.nonRegulated, depositType: DepositTypeEnum.fixedDeposit, + depositMin: null, + depositMax: null, depositValue: 1000, }) .set('Cookie', adminAccessToken) @@ -2257,12 +2261,12 @@ describe('Listing Controller Tests', () => { expect(newDBValues).toBeDefined(); expect(newDBValues.depositType).toEqual(DepositTypeEnum.fixedDeposit); - expect(newDBValues.depositRangeMax).toBeNull(); - expect(newDBValues.depositRangeMin).toBeNull(); + expect(newDBValues.depositMin).toBeNull(); + expect(newDBValues.depositMax).toBeNull(); expect(Number(newDBValues.depositValue)).toEqual(1000); }); - it("should fail when deposit is 'fixedDeposit' but 'depositRangeMin' and 'depositRangeMax' are set", async () => { + it("should fail when deposit is 'fixedDeposit' but 'depositMin' and 'depositMax' are set", async () => { const val = await constructFullListingData( undefined, undefined, @@ -2274,42 +2278,21 @@ describe('Listing Controller Tests', () => { .set({ passkey: process.env.API_PASS_KEY || '' }) .send({ ...val, + listingType: ListingTypeEnum.nonRegulated, depositType: DepositTypeEnum.fixedDeposit, depositValue: 1000, - depositRangeMin: 100, - depositRangeMax: 500, + depositMin: '100', + depositMax: '500', }) .set('Cookie', adminAccessToken) .expect(400); expect(res.body.message[0]).toEqual( - 'When deposit is of type "fixedDeposit" the "depositValue" must be filled and the "depositRangeMin"|"depositRangeMax" fields must be null', + 'When deposit is of type "fixedDeposit" the "depositValue" must be filled and the "depositMin"|"depositMax" fields must be null', ); }); - it("should fail when deposit is 'fixedDeposit' but 'depositValue' is missing", async () => { - const val = await constructFullListingData( - undefined, - undefined, - `create listing ${randomName()}`, - ); - - const res = await request(app.getHttpServer()) - .post('/listings') - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - ...val, - depositType: DepositTypeEnum.fixedDeposit, - }) - .set('Cookie', adminAccessToken) - .expect(400); - - expect(res.body.message[0]).toEqual( - 'When deposit is of type "fixedDeposit" the "depositValue" must be filled and the "depositRangeMin"|"depositRangeMax" fields must be null', - ); - }); - - it("should create listing when deposit is 'rangeDeposit', 'depositRangeMin' and 'depositRangeMax' are set and 'depositValue' is missing", async () => { + it("should create listing when deposit is 'rangeDeposit', and 'depositValue' is missing", async () => { const val = await constructFullListingData( undefined, undefined, @@ -2321,9 +2304,10 @@ describe('Listing Controller Tests', () => { .set({ passkey: process.env.API_PASS_KEY || '' }) .send({ ...val, + listingType: ListingTypeEnum.nonRegulated, depositType: DepositTypeEnum.depositRange, - depositRangeMin: 100, - depositRangeMax: 500, + depositMin: '100', + depositMax: '500', depositValue: null, }) .set('Cookie', adminAccessToken) @@ -2337,8 +2321,8 @@ describe('Listing Controller Tests', () => { expect(newDBValues).toBeDefined(); expect(newDBValues.depositType).toEqual(DepositTypeEnum.depositRange); - expect(Number(newDBValues.depositRangeMax)).toEqual(500); - expect(Number(newDBValues.depositRangeMin)).toEqual(100); + expect(Number(newDBValues.depositMax)).toEqual(500); + expect(Number(newDBValues.depositMin)).toEqual(100); expect(newDBValues.depositValue).toBeNull(); }); @@ -2354,41 +2338,17 @@ describe('Listing Controller Tests', () => { .set({ passkey: process.env.API_PASS_KEY || '' }) .send({ ...val, + listingType: ListingTypeEnum.nonRegulated, depositType: DepositTypeEnum.depositRange, depositValue: 1000, - depositRangeMin: 100, - depositRangeMax: 500, - }) - .set('Cookie', adminAccessToken) - .expect(400); - - expect(res.body.message[0]).toEqual( - 'When deposit is of type "depositRange" the "depositRangeMin" and "depositRangeMax" fields must be filled and "depositValue" must be null', - ); - }); - - it("should fail when deposit is 'rangeDeposit' but 'depositRangeMin' and 'depositRangeMax' are missing", async () => { - const val = await constructFullListingData( - undefined, - undefined, - `create listing ${randomName()}`, - ); - - const res = await request(app.getHttpServer()) - .post('/listings') - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - ...val, - depositType: DepositTypeEnum.depositRange, - depositValue: null, - depositRangeMin: null, - depositRangeMax: null, + depositMin: '100', + depositMax: '500', }) .set('Cookie', adminAccessToken) .expect(400); expect(res.body.message[0]).toEqual( - 'When deposit is of type "depositRange" the "depositRangeMin" and "depositRangeMax" fields must be filled and "depositValue" must be null', + 'When deposit is of type "depositRange" the "depositMin" and "depositMax" fields must be filled and "depositValue" must be null', ); }); }); diff --git a/api/test/unit/services/listing-csv-export.service.spec.ts b/api/test/unit/services/listing-csv-export.service.spec.ts index 95600a6304..49bb2e14ee 100644 --- a/api/test/unit/services/listing-csv-export.service.spec.ts +++ b/api/test/unit/services/listing-csv-export.service.spec.ts @@ -131,11 +131,11 @@ describe('Testing listing csv export service', () => { const content = fs.readFileSync('sampleFile.csv', 'utf8'); // Validate headers expect(content).toContain( - 'Listing Id,Created At Date,Jurisdiction,Listing Name,Listing Status,Publish Date,Last Updated,Copy or Original,Copied From,Developer,Building Street Address,Building City,Building State,Building Zip,Building Neighborhood,Building Year Built,Community Types,Latitude,Longitude,Listing Availability,Review Order,Lottery Date,Lottery Start,Lottery End,Lottery Notes,Housing Preferences,Application Fee,Deposit Helper Text,Deposit Min,Deposit Max,Deposit Type,Deposit Value,Deposit Range Min,Deposit Range Max,Costs Not Included,Property Amenities,Additional Accessibility,Unit Amenities,Smoking Policy,Pets Policy,Services Offered,Eligibility Rules - Credit History,Eligibility Rules - Rental History,Eligibility Rules - Criminal Background,Eligibility Rules - Rental Assistance,Building Selection Criteria,Important Program Rules,Required Documents,Special Notes,Waitlist,Leasing Agent Name,Leasing Agent Email,Leasing Agent Phone,Leasing Agent Title,Leasing Agent Office Hours,Leasing Agent Street Address,Leasing Agent Apt/Unit #,Leasing Agent City,Leasing Agent State,Leasing Agent Zip,Leasing Agency Mailing Address,Leasing Agency Mailing Address Street 2,Leasing Agency Mailing Address City,Leasing Agency Mailing Address State,Leasing Agency Mailing Address Zip,Leasing Agency Pickup Address,Leasing Agency Pickup Address Street 2,Leasing Agency Pickup Address City,Leasing Agency Pickup Address State,Leasing Agency Pickup Address Zip,Leasing Pick Up Office Hours,Digital Application,Digital Application URL,Paper Application,Paper Application URL,Referral Opportunity,Can applications be mailed in?,Can applications be picked up?,Can applications be dropped off?,Postmark,Additional Application Submission Notes,Application Due Date,Application Due Time,Open House,Partners Who Have Access', + 'Listing Id,Created At Date,Jurisdiction,Listing Name,Listing Status,Publish Date,Last Updated,Copy or Original,Copied From,Developer,Building Street Address,Building City,Building State,Building Zip,Building Neighborhood,Building Year Built,Community Types,Latitude,Longitude,Listing Availability,Review Order,Lottery Date,Lottery Start,Lottery End,Lottery Notes,Housing Preferences,Application Fee,Deposit Helper Text,Deposit Type,Deposit Value,Deposit Min,Deposit Max,Costs Not Included,Property Amenities,Additional Accessibility,Unit Amenities,Smoking Policy,Pets Policy,Services Offered,Eligibility Rules - Credit History,Eligibility Rules - Rental History,Eligibility Rules - Criminal Background,Eligibility Rules - Rental Assistance,Building Selection Criteria,Important Program Rules,Required Documents,Special Notes,Waitlist,Leasing Agent Name,Leasing Agent Email,Leasing Agent Phone,Leasing Agent Title,Leasing Agent Office Hours,Leasing Agent Street Address,Leasing Agent Apt/Unit #,Leasing Agent City,Leasing Agent State,Leasing Agent Zip,Leasing Agency Mailing Address,Leasing Agency Mailing Address Street 2,Leasing Agency Mailing Address City,Leasing Agency Mailing Address State,Leasing Agency Mailing Address Zip,Leasing Agency Pickup Address,Leasing Agency Pickup Address Street 2,Leasing Agency Pickup Address City,Leasing Agency Pickup Address State,Leasing Agency Pickup Address Zip,Leasing Pick Up Office Hours,Digital Application,Digital Application URL,Paper Application,Paper Application URL,Referral Opportunity,Can applications be mailed in?,Can applications be picked up?,Can applications be dropped off?,Postmark,Additional Application Submission Notes,Application Due Date,Application Due Time,Open House,Partners Who Have Access', ); // Validate first row expect(content).toContain( - '"listing1-ID","10-02-2025 11:38:19AM PDT","jurisdiction-Name","listing1-Name","Public","10-02-2025 11:38:19AM PDT","10-02-2025 11:38:19AM PDT","Original",,"developer","123 main st","Bloomington","BL","01234","neighborhood","2025",,"latitude","longitude","Available Units",,"10-02-2025","11:38AM PDT","01:38PM PDT","lottery note",,"$45","sample deposit helper text","$12","$120",,,,,"sample costs not included","sample amenities","sample accessibility","sample unit amenities","sample smoking policy","sample pet policy","sample services offered",,,,,,,,,"No","Name of leasing agent","Email of leasing agent",,"Title of leasing agent","office hours","321 main st",,"Bloomington","BL","01234","456 main st",,"Bloomington","BL","01234","789 main st",,"Bloomington","BL","01234",,"No",,"No",,"No","No","No","No",,,"10-02-2025","11:38AM PDT",,"userFirst userLast"', + '"listing1-ID","10-02-2025 11:38:19AM PDT","jurisdiction-Name","listing1-Name","Public","10-02-2025 11:38:19AM PDT","10-02-2025 11:38:19AM PDT","Original",,"developer","123 main st","Bloomington","BL","01234","neighborhood","2025",,"latitude","longitude","Available Units",,"10-02-2025","11:38AM PDT","01:38PM PDT","lottery note",,"$45","sample deposit helper text",,,"$12","$120","sample costs not included","sample amenities","sample accessibility","sample unit amenities","sample smoking policy","sample pet policy","sample services offered",,,,,,,,,"No","Name of leasing agent","Email of leasing agent",,"Title of leasing agent","office hours","321 main st",,"Bloomington","BL","01234","456 main st",,"Bloomington","BL","01234","789 main st",,"Bloomington","BL","01234",,"No",,"No",,"No","No","No","No",,,"10-02-2025","11:38AM PDT",,"userFirst userLast"', ); }); it.todo('should create the listing csv with feature flagged columns'); diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts index 87300f4bce..a100b3f9a4 100644 --- a/api/test/unit/services/listing.service.spec.ts +++ b/api/test/unit/services/listing.service.spec.ts @@ -608,7 +608,7 @@ describe('Testing listing service', () => { listingsBuildingAddress: true, requestedChangesUser: true, reservedCommunityTypes: true, - + requiredDocumentsList: true, listingImages: { include: { assets: true, @@ -2373,6 +2373,7 @@ describe('Testing listing service', () => { listingsBuildingAddress: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, lastUpdatedByUser: true, listingImages: { include: { @@ -2890,6 +2891,7 @@ describe('Testing listing service', () => { requestedChangesUser: true, lastUpdatedByUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, listingImages: { include: { assets: true, @@ -3193,6 +3195,7 @@ describe('Testing listing service', () => { listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -3318,6 +3321,7 @@ describe('Testing listing service', () => { listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -3689,6 +3693,7 @@ describe('Testing listing service', () => { listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -3828,6 +3833,7 @@ describe('Testing listing service', () => { listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -4166,6 +4172,7 @@ describe('Testing listing service', () => { listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -4274,6 +4281,7 @@ describe('Testing listing service', () => { listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -4373,6 +4381,7 @@ describe('Testing listing service', () => { listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -4804,6 +4813,7 @@ describe('Testing listing service', () => { listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { diff --git a/shared-helpers/src/locales/ar.json b/shared-helpers/src/locales/ar.json index 4d3531101d..9dbbcd49a3 100644 --- a/shared-helpers/src/locales/ar.json +++ b/shared-helpers/src/locales/ar.json @@ -768,6 +768,15 @@ "listings.rentalHistory": "تاريخ الإيجار", "listings.rePricing": "إعادة التسعير", "listings.requiredDocuments": "المستندات المطلوبة", + "listings.requiredDocuments.socialSecurityCard": "بطاقة الضمان الاجتماعي،", + "listings.requiredDocuments.birthCertificate": "شهادة الميلاد (لجميع أفراد الأسرة ١٨ عامًا فأكثر)", + "listings.requiredDocuments.governmentIssuedId": "بطاقة هوية صادرة عن جهة حكومية (لجميع أفراد الأسرة ١٨ عامًا فأكثر)", + "listings.requiredDocuments.residencyDocuments": "وثائق الهجرة/الإقامة (البطاقة الخضراء، إلخ)", + "listings.requiredDocuments.proofOfAssets": "إثبات الأصول (كشوف الحسابات المصرفية، إلخ)", + "listings.requiredDocuments.proofOfIncome": "إثبات دخل الأسرة (قسائم شيكات، نموذج W-2، إلخ)", + "listings.requiredDocuments.proofOfCustody": "إثبات الحضانة/الوصاية", + "listings.requiredDocuments.currentLandlordReference": "مرجع المالك الحالي", + "listings.requiredDocuments.previousLandlordReference": "مرجع المالك السابق", "listings.reservedCommunityBuilding": "مبنى %{type}", "listings.reservedCommunitySeniorTitle": "مبنى كبار السن", "listings.reservedCommunityTitleDefault": "مبنى محجوز", diff --git a/shared-helpers/src/locales/bn.json b/shared-helpers/src/locales/bn.json index 3fcf801d42..2c2cb06225 100644 --- a/shared-helpers/src/locales/bn.json +++ b/shared-helpers/src/locales/bn.json @@ -768,6 +768,15 @@ "listings.rentalHistory": "ভাড়ার ইতিহাস", "listings.rePricing": "পুনরায় মূল্য নির্ধারণ", "listings.requiredDocuments": "প্রয়োজনীয় কাগজপত্র", + "listings.requiredDocuments.socialSecurityCard": "সামাজিক নিরাপত্তা কার্ড", + "listings.requiredDocuments.birthCertificate": "জন্ম সনদ (১৮+ বয়সী সকল পরিবারের সদস্য)", + "listings.requiredDocuments.governmentIssuedId": "সরকার কর্তৃক প্রদত্ত পরিচয়পত্র (১৮+ বয়সী সকল পরিবারের সদস্য)", + "listings.requiredDocuments.residencyDocuments": "অভিবাসন/আবাসিক নথি (সবুজ কার্ড, ইত্যাদি)", + "listings.requiredDocuments.proofOfAssets": "সম্পদ প্রমাণ (ব্যাংক স্টেটমেন্ট, ইত্যাদি)", + "listings.requiredDocuments.proofOfIncome": "পরিবারের আয়ের প্রমাণ (চেক স্টাব, W-2, ইত্যাদি)", + "listings.requiredDocuments.proofOfCustody": "হেফাজত/অভিভাবকের প্রমাণ", + "listings.requiredDocuments.currentLandlordReference": "বর্তমান বাড়িওয়ালার রেফারেন্স", + "listings.requiredDocuments.previousLandlordReference": "পূর্ববর্তী বাড়িওয়ালার রেফারেন্স", "listings.reservedCommunityBuilding": "%{টাইপ} বিল্ডিং", "listings.reservedCommunitySeniorTitle": "সিনিয়র ভবন", "listings.reservedCommunityTitleDefault": "সংরক্ষিত ভবন", diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index 3a969528e4..878a4bc889 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -768,6 +768,16 @@ "listings.rentalHistory": "Rental history", "listings.rePricing": "Re-pricing", "listings.requiredDocuments": "Required documents", + "listings.requiredDocumentsAdditionalInfo": "Required documents (Additional Info)", + "listings.requiredDocuments.socialSecurityCard": "Social Security card", + "listings.requiredDocuments.birthCertificate": "Birth Certificate (all household members 18+)", + "listings.requiredDocuments.governmentIssuedId": "Government-issued ID (all household members 18+)", + "listings.requiredDocuments.residencyDocuments": "Immigration/Residency documents (green card, etc.)", + "listings.requiredDocuments.proofOfAssets": "Proof of Assets (bank statements, etc.)", + "listings.requiredDocuments.proofOfIncome": "Proof of household income (check stubs, W-2, etc.)", + "listings.requiredDocuments.proofOfCustody": "Proof of Custody/Guardianship", + "listings.requiredDocuments.currentLandlordReference": "Current landlord reference", + "listings.requiredDocuments.previousLandlordReference": "Previous landlord reference", "listings.reservedCommunityBuilding": "%{type} building", "listings.reservedCommunitySeniorTitle": "Senior building", "listings.reservedCommunityTitleDefault": "Reserved building", diff --git a/shared-helpers/src/locales/tl.json b/shared-helpers/src/locales/tl.json index f5e8c6da22..151da92673 100644 --- a/shared-helpers/src/locales/tl.json +++ b/shared-helpers/src/locales/tl.json @@ -768,6 +768,15 @@ "listings.rentalHistory": "Kasaysayan ng pagrenta", "listings.rePricing": "Pagbabago ng Presyo", "listings.requiredDocuments": "Mga kinakailangang dokumento", + "listings.requiredDocuments.socialSecurityCard": "Kard ng Seguridad Panlipunan", + "listings.requiredDocuments.birthCertificate": "Sertipiko ng Kapanganakan (lahat ng miyembro ng sambahayan na may edad 18 pataas)", + "listings.requiredDocuments.governmentIssuedId": "ID na inisyu ng pamahalaan (lahat ng miyembro ng sambahayan na may edad 18 pataas)", + "listings.requiredDocuments.residencyDocuments": "Mga dokumento ng Imigrasyon/Paninirahan (green card, atbp.)", + "listings.requiredDocuments.proofOfAssets": "Patunay ng mga Ari-arian (mga pahayag ng bangko, atbp.)", + "listings.requiredDocuments.proofOfIncome": "Patunay ng kita ng sambahayan (mga payslip, W-2, atbp.)", + "listings.requiredDocuments.proofOfCustody": "Patunay ng Pagkakaroon ng Pag-iingat o Pagiging Tagapag-alaga", + "listings.requiredDocuments.currentLandlordReference": "Reperensya mula sa kasalukuyang may-ari ng inuupahan", + "listings.requiredDocuments.previousLandlordReference": "Reperensya mula sa dating may-ari ng inuupahan", "listings.reservedCommunityBuilding": "%{type} gusali", "listings.reservedCommunitySeniorTitle": "Matandang gusali", "listings.reservedCommunityTitleDefault": "Nakareserbang gusali", diff --git a/shared-helpers/src/locales/zh.json b/shared-helpers/src/locales/zh.json index 58fc3336f7..cb1b4dd9a2 100644 --- a/shared-helpers/src/locales/zh.json +++ b/shared-helpers/src/locales/zh.json @@ -768,6 +768,15 @@ "listings.rentalHistory": "租赁历史", "listings.rePricing": "重新定價", "listings.requiredDocuments": "所需文件", + "listings.requiredDocuments.socialSecurityCard": "社会安全卡", + "listings.requiredDocuments.birthCertificate": "出生证明(所有18岁及以上的家庭成员)", + "listings.requiredDocuments.governmentIssuedId": "政府签发的身份证件(所有18岁及以上的家庭成员)", + "listings.requiredDocuments.residencyDocuments": "移民/居留文件(绿卡等)", + "listings.requiredDocuments.proofOfAssets": "资产证明(银行对账单等)", + "listings.requiredDocuments.proofOfIncome": "家庭收入证明(工资单、W-2表等)", + "listings.requiredDocuments.proofOfCustody": "监护权/监护人证明", + "listings.requiredDocuments.currentLandlordReference": "现任房东推荐信", + "listings.requiredDocuments.previousLandlordReference": "前任房东推荐信", "listings.reservedCommunityBuilding": "%{type} 樓宇", "listings.reservedCommunitySeniorTitle": "高级楼", "listings.reservedCommunityTitleDefault": "预留楼", diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index b1fdc5bfe6..66cfd6179b 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -3101,6 +3101,35 @@ export interface IdDTO { ordinal?: number } +export interface ListingDocuments { + /** */ + socialSecurityCard?: boolean + + /** */ + currentLandlordReference?: boolean + + /** */ + birthCertificate?: boolean + + /** */ + previousLandlordReference?: boolean + + /** */ + governmentIssuedId?: boolean + + /** */ + proofOfAssets?: boolean + + /** */ + proofOfIncome?: boolean + + /** */ + residencyDocuments?: boolean + + /** */ + proofOfCustody?: boolean +} + export interface MultiselectLink { /** */ title: string @@ -3688,6 +3717,9 @@ export interface UnitGroup { /** */ flatRentValueTo?: number + /** */ + monthlyRent?: number + /** */ floorMin?: number @@ -3967,6 +3999,9 @@ export interface UnitsSummary { /** */ flatRentValueTo?: number + + /** */ + monthlyRent?: number } export interface ApplicationLotteryTotal { @@ -4142,12 +4177,6 @@ export interface Listing { /** */ depositValue?: number - /** */ - depositRangeMin?: number - - /** */ - depositRangeMax?: number - /** */ depositHelperText?: string @@ -4196,6 +4225,9 @@ export interface Listing { /** */ requiredDocuments?: string + /** */ + requiredDocumentsList?: ListingDocuments + /** */ specialNotes?: string @@ -4519,6 +4551,9 @@ export interface UnitGroupCreate { /** */ flatRentValueTo?: number + /** */ + monthlyRent?: number + /** */ floorMin?: number @@ -4655,6 +4690,9 @@ export interface UnitsSummaryCreate { /** */ flatRentValueTo?: number + + /** */ + monthlyRent?: number } export interface ListingImageCreate { @@ -4835,12 +4873,6 @@ export interface ListingCreate { /** */ depositValue?: number - /** */ - depositRangeMin?: number - - /** */ - depositRangeMax?: number - /** */ depositHelperText?: string @@ -4889,6 +4921,9 @@ export interface ListingCreate { /** */ requiredDocuments?: string + /** */ + requiredDocumentsList?: ListingDocuments + /** */ specialNotes?: string @@ -5181,12 +5216,6 @@ export interface ListingUpdate { /** */ depositValue?: number - /** */ - depositRangeMin?: number - - /** */ - depositRangeMax?: number - /** */ depositHelperText?: string @@ -5235,6 +5264,9 @@ export interface ListingUpdate { /** */ requiredDocuments?: string + /** */ + requiredDocumentsList?: ListingDocuments + /** */ specialNotes?: string @@ -7770,6 +7802,11 @@ export enum AlternateContactRelationship { "noContact" = "noContact", } +export enum RentTypeEnum { + "fixedRent" = "fixedRent", + "rentRange" = "rentRange", +} + export enum HouseholdMemberRelationship { "spouse" = "spouse", "registeredDomesticPartner" = "registeredDomesticPartner", diff --git a/shared-helpers/src/utilities/formKeys.ts b/shared-helpers/src/utilities/formKeys.ts index 95099b3fe2..1c6ff3fbfc 100644 --- a/shared-helpers/src/utilities/formKeys.ts +++ b/shared-helpers/src/utilities/formKeys.ts @@ -277,6 +277,18 @@ export const listingFeatures = [ "loweredCabinets", ] +export const listingRequiredDocumentsOptions = [ + "socialSecurityCard", + "currentLandlordReference", + "birthCertificate", + "previousLandlordReference", + "governmentIssuedId", + "proofOfAssets", + "proofOfIncome", + "residencyDocuments", + "proofOfCustody", +] + export enum RoleOption { Administrator = "administrator", Partner = "partner", diff --git a/sites/partners/__tests__/components/applications/sections/helpers.tsx b/sites/partners/__tests__/components/applications/sections/helpers.tsx index abe6258cbc..e5be2acfec 100644 --- a/sites/partners/__tests__/components/applications/sections/helpers.tsx +++ b/sites/partners/__tests__/components/applications/sections/helpers.tsx @@ -1,8 +1,14 @@ import { FormProvider, useForm } from "react-hook-form" -import { FormTypes } from "../../../../src/lib/applications/FormTypes" import React from "react" +import { formDefaults, FormListing } from "../../../../src/lib/listings/formTypes" -export const FormProviderWrapper = ({ children }: React.PropsWithChildren) => { - const formMethods = useForm({}) +export const FormProviderWrapper = ({ + children, + values, +}: React.PropsWithChildren<{ values?: Partial }>) => { + const formMethods = useForm({ + defaultValues: { ...formDefaults, ...values }, + shouldUnregister: false, + }) return {children} } diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/UnitGroupForm.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/UnitGroupForm.test.tsx index 1efd35d857..0a0f262f9b 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/UnitGroupForm.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/UnitGroupForm.test.tsx @@ -14,6 +14,7 @@ import { mockNextRouter } from "../../../testUtils" import { FormProviderWrapper } from "../../applications/sections/helpers" import { TempUnitGroup } from "../../../../src/lib/listings/formTypes" import UnitGroupForm from "../../../../src/components/listings/PaperListingForm/UnitGroupForm" +import { EnumListingListingType } from "@bloom-housing/shared-helpers/src/types/backend-swagger" const server = setupServer() @@ -84,7 +85,13 @@ describe("", () => { expect(screen.getByLabelText(/4 bedroom/i)).toBeInTheDocument() // Details Section - expect(screen.getByLabelText(/Affordable Unit Group Quantity/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Unit Group Quantity/i)).toBeInTheDocument() + expect(screen.queryByRole("group", { name: /^rent type$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^monthly rent$/i })).not.toBeInTheDocument() + expect( + screen.queryByRole("spinbutton", { name: /^monthly rent from$/i }) + ).not.toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^monthly rent to$/i })).not.toBeInTheDocument() expect(screen.getByLabelText(/Minimum occupancy/i)).toBeInTheDocument() expect(screen.getByLabelText(/Max occupancy/i)).toBeInTheDocument() expect(screen.getByLabelText(/Min square footage/i)).toBeInTheDocument() @@ -275,6 +282,102 @@ describe("", () => { ) }) + it("should render the unit group form for non-regulated listings", async () => { + render( + + + + + + ) + + expect(screen.getAllByRole("heading", { level: 2, name: /details/i })).toHaveLength(2) + + // Unit Types Section + expect(screen.getByText(/unit type/i)).toBeInTheDocument() + expect(await screen.findByRole("checkbox", { name: /studio/i })).toBeInTheDocument() + expect(screen.getByRole("checkbox", { name: /1 bedroom/i })).toBeInTheDocument() + expect(screen.getByRole("checkbox", { name: /2 bedroom/i })).toBeInTheDocument() + expect(screen.getByRole("checkbox", { name: /3 bedroom/i })).toBeInTheDocument() + expect(screen.getByRole("checkbox", { name: /4 bedroom/i })).toBeInTheDocument() + + // Details Section + expect(screen.getByRole("spinbutton", { name: /Unit Group Quantity/i })).toBeInTheDocument() + + expect(screen.getByRole("group", { name: /^rent type$/i })).toBeInTheDocument() + const fixedRentOption = screen.getByRole("radio", { name: /^fixed rent$/i }) + const rentRangeOption = screen.getByRole("radio", { name: /^rent range$/i }) + expect(fixedRentOption).toBeInTheDocument() + expect(fixedRentOption).not.toBeChecked() + expect(rentRangeOption).toBeInTheDocument() + expect(rentRangeOption).not.toBeChecked() + + expect(screen.queryByRole("spinbutton", { name: /^monthly rent$/i })).not.toBeInTheDocument() + expect( + screen.queryByRole("spinbutton", { name: /^monthly rent from$/i }) + ).not.toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^monthly rent to$/i })).not.toBeInTheDocument() + + await userEvent.click(fixedRentOption) + expect(fixedRentOption).toBeChecked() + expect(rentRangeOption).not.toBeChecked() + + expect(screen.getByRole("spinbutton", { name: /^monthly rent$/i })).toBeInTheDocument() + expect( + screen.queryByRole("spinbutton", { name: /^monthly rent from$/i }) + ).not.toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^monthly rent to$/i })).not.toBeInTheDocument() + + await userEvent.click(rentRangeOption) + expect(fixedRentOption).not.toBeChecked() + expect(rentRangeOption).toBeChecked() + + expect(screen.queryByRole("spinbutton", { name: /^monthly rent$/i })).not.toBeInTheDocument() + expect(screen.getByRole("spinbutton", { name: /^monthly rent from$/i })).toBeInTheDocument() + expect(screen.getByRole("spinbutton", { name: /^monthly rent to$/i })).toBeInTheDocument() + + expect(screen.getByRole("combobox", { name: /Minimum occupancy/i })).toBeInTheDocument() + expect(screen.getByRole("combobox", { name: /Max occupancy/i })).toBeInTheDocument() + expect( + screen.queryByRole("spinbutton", { name: /Min square footage/i }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole("spinbutton", { name: /Max square footage/i }) + ).not.toBeInTheDocument() + expect(screen.queryByRole("combobox", { name: /Minimum floor/i })).not.toBeInTheDocument() + expect(screen.queryByRole("combobox", { name: /Maximum floor/i })).not.toBeInTheDocument() + expect(screen.getByRole("combobox", { name: /Min number of bathrooms/i })).toBeInTheDocument() + expect(screen.getByRole("combobox", { name: /Max number of bathrooms/i })).toBeInTheDocument() + + // Availability Section + expect(screen.getByRole("heading", { level: 2, name: /availability/i })).toBeInTheDocument() + + expect(screen.getByLabelText(/Unit group vacancies/i)).toBeInTheDocument() + expect(screen.queryByRole("group", { name: /Waitlist status/i })).not.toBeInTheDocument() + expect(screen.queryByLabelText(/^Open$/i)).not.toBeInTheDocument() + expect(screen.queryByLabelText(/Closed/i)).not.toBeInTheDocument() + + // Eligibility Section + expect(screen.queryByRole("heading", { level: 2, name: "Eligibility" })).not.toBeInTheDocument() + expect(screen.queryByRole("table")).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Add AMI level" })).not.toBeInTheDocument() + }) + describe("ami levels table delete functionality", () => { it("should remove ami chart on delete click", async () => { render( @@ -587,7 +690,7 @@ describe("", () => { await userEvent.click(studioButton) const quantityInput = screen.getByRole("spinbutton", { - name: /affordable unit group quantity/i, + name: /unit group quantity/i, }) await userEvent.type(quantityInput, "4") diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/index.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/index.test.tsx index 42b3d98b77..16ba009b54 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/index.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/index.test.tsx @@ -377,8 +377,6 @@ describe("add listing", () => { "Reserved community description", "Units", "Application fee", - "Deposit min", - "Deposit max", "Deposit helper text", "Costs not included", "Property amenities", @@ -496,8 +494,6 @@ describe("add listing", () => { "Home type", "Units", "Application fee", - "Deposit min", - "Deposit max", "Deposit helper text", "Costs not included", "Property amenities", diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalDetails.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalDetails.test.tsx new file mode 100644 index 0000000000..a6da7d734a --- /dev/null +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalDetails.test.tsx @@ -0,0 +1,180 @@ +import React from "react" +import { FormProvider, useForm } from "react-hook-form" +import { formDefaults, FormListing } from "../../../../../src/lib/listings/formTypes" +import { setupServer } from "msw/lib/node" +import { mockNextRouter } from "../../../../testUtils" +import { render, screen } from "@testing-library/react" +import { + EnumListingListingType, + FeatureFlagEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import AdditionalDetails from "../../../../../src/components/listings/PaperListingForm/sections/AdditionalDetails" +import { AuthContext } from "@bloom-housing/shared-helpers" +import { jurisdiction, user } from "@bloom-housing/shared-helpers/__tests__/testHelpers" + +const FormComponent = ({ children, values }: { values?: Partial; children }) => { + const formMethods = useForm({ + defaultValues: { ...formDefaults, ...values }, + shouldUnregister: false, + }) + return {children} +} + +const server = setupServer() + +// Enable API mocking before tests. +beforeAll(() => { + server.listen() + mockNextRouter() +}) + +// Reset any runtime request handlers we may add during the tests. +afterEach(() => server.resetHandlers()) + +// Disable API mocking after the tests are done. +afterAll(() => server.close()) + +describe("AdditionalDetails", () => { + it("should render the AdditionalDetails section with default/regulated fields", async () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + // Check for the section heading + expect( + await screen.findByRole("heading", { level: 2, name: /additional details/i }) + ).toBeInTheDocument() + expect( + screen.getByText("Are there any other required documents and selection criteria?") + ).toBeInTheDocument() + + expect(screen.getByRole("textbox", { name: /^required documents$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^important program rules$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^required documents$/i })).toBeInTheDocument() + + expect( + screen.queryByRole("textbox", { name: /^required documents (additional info)$/i }) + ).not.toBeInTheDocument() + expect(screen.queryByRole("group", { name: /^required documents$/i })).not.toBeInTheDocument() + }) + + it("should render the AdditionalDetails section with non-regulated fields", async () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + // Check for the section heading + expect( + await screen.findByRole("heading", { level: 2, name: /additional details/i }) + ).toBeInTheDocument() + expect( + screen.getByText("Are there any other required documents and selection criteria?") + ).toBeInTheDocument() + + expect(screen.getByRole("group", { name: /^required documents$/i })).toBeInTheDocument() + + const socialSecurityCard = screen.getByRole("checkbox", { name: /^social security card$/i }) + const currentLandlordReference = screen.getByRole("checkbox", { + name: /^current landlord reference$/i, + }) + const birthCertificate = screen.getByRole("checkbox", { + name: /^birth certificate \(all household members 18\+\)$/i, + }) + const previousLandlordReference = screen.getByRole("checkbox", { + name: /^previous landlord reference$/i, + }) + const governmentIssuedId = screen.getByRole("checkbox", { + name: /^government-issued ID \(all household members 18\+\)$/i, + }) + const proofOfAssets = screen.getByRole("checkbox", { + name: /^proof of assets \(bank statements, etc.\)$/i, + }) + const proofOfIncome = screen.getByRole("checkbox", { + name: /^proof of household income \(check stubs, W-2, etc.\)$/i, + }) + const residencyDocuments = screen.getByRole("checkbox", { + name: /^immigration\/residency documents \(green card, etc.\)$/i, + }) + const proofOfCustody = screen.getByRole("checkbox", { + name: /^proof of custody\/guardianship$/i, + }) + + expect(socialSecurityCard).toBeInTheDocument() + expect(socialSecurityCard).toBeChecked() + expect(currentLandlordReference).toBeInTheDocument() + expect(currentLandlordReference).toBeChecked() + expect(birthCertificate).toBeInTheDocument() + expect(birthCertificate).toBeChecked() + expect(previousLandlordReference).toBeInTheDocument() + expect(previousLandlordReference).toBeChecked() + expect(governmentIssuedId).toBeInTheDocument() + expect(governmentIssuedId).not.toBeChecked() + expect(proofOfAssets).toBeInTheDocument() + expect(proofOfAssets).not.toBeChecked() + expect(proofOfIncome).toBeInTheDocument() + expect(proofOfIncome).not.toBeChecked() + expect(residencyDocuments).toBeInTheDocument() + expect(residencyDocuments).not.toBeChecked() + expect(proofOfCustody).toBeInTheDocument() + expect(proofOfCustody).not.toBeChecked() + + expect( + screen.getByRole("textbox", { name: /^required documents \(additional info\)$/i }) + ).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^important program rules$/i })).toBeInTheDocument() + + expect(screen.queryByRole("textbox", { name: /^required documents$/i })).not.toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalFees.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalFees.test.tsx new file mode 100644 index 0000000000..6a5f38309c --- /dev/null +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalFees.test.tsx @@ -0,0 +1,187 @@ +import React from "react" +import { FormProvider, useForm } from "react-hook-form" +import { formDefaults, FormListing } from "../../../../../src/lib/listings/formTypes" +import { setupServer } from "msw/lib/node" +import { mockNextRouter } from "../../../../testUtils" +import { render, screen } from "@testing-library/react" +import AdditionalFees from "../../../../../src/components/listings/PaperListingForm/sections/AdditionalFees" +import { AuthContext } from "@bloom-housing/shared-helpers" +import { jurisdiction, user } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { + EnumListingListingType, + FeatureFlagEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import userEvent from "@testing-library/user-event" + +const FormComponent = ({ children, values }: { values?: Partial; children }) => { + const formMethods = useForm({ + defaultValues: { ...formDefaults, ...values }, + shouldUnregister: false, + }) + return {children} +} + +const server = setupServer() + +// Enable API mocking before tests. +beforeAll(() => { + server.listen() + mockNextRouter() +}) + +// Reset any runtime request handlers we may add during the tests. +afterEach(() => server.resetHandlers()) + +// Disable API mocking after the tests are done. +afterAll(() => server.close()) + +describe("AdditionalFees", () => { + it("should render the base AdditionalFees", async () => { + render( + false, + }} + > + + + + + ) + + expect( + await screen.findByRole("heading", { level: 2, name: /^additional fees$/i }) + ).toBeInTheDocument() + expect( + screen.getByText(/^tell us about any other fees required by the applicant.$/i) + ).toBeInTheDocument() + + expect(screen.getByRole("textbox", { name: /^application fee$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^deposit helper text$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^costs not included$/i })).toBeInTheDocument() + + expect(screen.queryByRole("group", { name: /^utilities included$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^water$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^gas$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^trash$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^sewer$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^electricity$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^cable$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^phone$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^internet$/i })).not.toBeInTheDocument() + }) + + it("should render the AdditionalFees section with utlities included", async () => { + render( + + featureFlag === FeatureFlagEnum.enableUtilitiesIncluded, + }} + > + + + + + ) + + expect( + await screen.findByRole("heading", { level: 2, name: /^additional fees$/i }) + ).toBeInTheDocument() + expect( + screen.getByText(/^tell us about any other fees required by the applicant.$/i) + ).toBeInTheDocument() + + expect(screen.getByRole("textbox", { name: /^application fee$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^deposit helper text$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^costs not included$/i })).toBeInTheDocument() + + expect(screen.getByRole("group", { name: /^utilities included$/i })).toBeInTheDocument() + const water = screen.getByRole("checkbox", { name: /^water$/i }) + const gas = screen.getByRole("checkbox", { name: /^gas$/i }) + const trash = screen.getByRole("checkbox", { name: /^trash$/i }) + const sewer = screen.getByRole("checkbox", { name: /^sewer$/i }) + const electricity = screen.getByRole("checkbox", { name: /^electricity$/i }) + const cable = screen.getByRole("checkbox", { name: /^cable$/i }) + const phone = screen.getByRole("checkbox", { name: /^phone$/i }) + const internet = screen.getByRole("checkbox", { name: /^internet$/i }) + expect(water).toBeInTheDocument() + expect(water).toBeChecked() + expect(gas).toBeInTheDocument() + expect(gas).toBeChecked() + expect(trash).toBeInTheDocument() + expect(trash).toBeChecked() + expect(sewer).toBeInTheDocument() + expect(sewer).toBeChecked() + expect(electricity).toBeInTheDocument() + expect(electricity).not.toBeChecked() + expect(cable).toBeInTheDocument() + expect(cable).not.toBeChecked() + expect(phone).toBeInTheDocument() + expect(phone).not.toBeChecked() + expect(internet).toBeInTheDocument() + expect(internet).not.toBeChecked() + }) + + it("should render the AdditionalFees section for non regulated fields", async () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + expect( + await screen.findByRole("heading", { level: 2, name: /^additional fees$/i }) + ).toBeInTheDocument() + expect( + screen.getByText(/^tell us about any other fees required by the applicant.$/i) + ).toBeInTheDocument() + + expect(screen.getByRole("group", { name: /^deposit type$/i })).toBeInTheDocument() + const fixedDepositOption = screen.getByRole("radio", { name: /^fixed deposit$/i }) + const depositRangeOption = screen.getByRole("radio", { name: /^deposit range$/i }) + expect(fixedDepositOption).toBeInTheDocument() + expect(fixedDepositOption).toBeChecked() + expect(depositRangeOption).toBeInTheDocument() + expect(depositRangeOption).not.toBeChecked() + + expect(screen.getByRole("spinbutton", { name: /^deposit$/i })).toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^deposit min$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^deposit max$/i })).not.toBeInTheDocument() + + await userEvent.click(depositRangeOption) + + expect(fixedDepositOption).not.toBeChecked() + expect(depositRangeOption).toBeChecked() + + expect(screen.getByRole("spinbutton", { name: /^deposit min$/i })).toBeInTheDocument() + expect(screen.getByRole("spinbutton", { name: /^deposit max$/i })).toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^deposit$/i })).not.toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx index 4f021e0bef..e0aa57569d 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx @@ -2,7 +2,7 @@ import React from "react" import { rest } from "msw" import { setupServer } from "msw/node" import userEvent from "@testing-library/user-event" -import { screen } from "@testing-library/react" +import { screen, within } from "@testing-library/react" import { FormProvider, useForm } from "react-hook-form" import { FeatureFlagEnum, @@ -200,4 +200,102 @@ describe("ListingIntro", () => { expect(screen.getByRole("textbox", { name: "Listing file number" })).toBeInTheDocument() }) + + it("should render the ListingIntro section with regulated fields when feature flag is on", async () => { + document.cookie = "access-token-available=True" + server.use( + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res( + ctx.json({ + jurisdictions: [ + { + id: "JurisdictionA", + name: "JurisdictionA", + featureFlags: [{ name: FeatureFlagEnum.enableNonRegulatedListings, active: false }], + }, + { + id: "JurisdictionB", + name: "JurisdictionB", + featureFlags: [{ name: FeatureFlagEnum.enableNonRegulatedListings, active: true }], + }, + ], + }) + ) + }) + ) + + render( + + + + ) + + const jurisdictionSelect = await screen.findByRole("combobox", { name: /jurisdiction/i }) + expect(jurisdictionSelect).toBeInTheDocument() + await userEvent.selectOptions(jurisdictionSelect, "JurisdictionA") + + // Verify that without the feature flag new fields are not renderd + expect( + await screen.queryByRole("group", { name: "What kind of listing is this?" }) + ).not.toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^housing developer$/i })).toBeInTheDocument() + expect( + screen.queryByRole("textbox", { name: /^property management account$/i }) + ).not.toBeInTheDocument() + + let ebllQuestionLabel = screen.queryByRole("group", { + name: "Has this property received HUD EBLL clearance?", + }) + + // Switch to the jurisdiction with the feature flag enabled + await userEvent.selectOptions(jurisdictionSelect, "JurisdictionB") + + expect( + await screen.findByRole("group", { name: "What kind of listing is this?" }) + ).toBeInTheDocument() + const requlatedListingOption = screen.getByRole("radio", { name: /^regulated$/i }) + const nonRequlatedListingOption = screen.getByRole("radio", { name: /^non-regulated$/i }) + expect(requlatedListingOption).toBeInTheDocument() + expect(requlatedListingOption).toBeChecked() + expect(nonRequlatedListingOption).toBeInTheDocument() + expect(nonRequlatedListingOption).not.toBeChecked() + + ebllQuestionLabel = screen.queryByRole("group", { + name: "Has this property received HUD EBLL clearance?", + }) + expect(ebllQuestionLabel).not.toBeInTheDocument() + + await userEvent.click(nonRequlatedListingOption) + + expect( + screen.getByRole("textbox", { name: /^property management account$/i }) + ).toBeInTheDocument() + expect(screen.queryByRole("textbox", { name: /^housing developer$/i })).not.toBeInTheDocument() + + ebllQuestionLabel = screen.queryByRole("group", { + name: "Has this property received HUD EBLL clearance?", + }) + expect(ebllQuestionLabel).toBeInTheDocument() + const ebllQuestionContainer = ebllQuestionLabel.parentElement + const ebllYesOption = within(ebllQuestionContainer).getByRole("radio", { name: /^yes$/i }) + const ebllNoOption = within(ebllQuestionContainer).getByRole("radio", { name: /^no$/i }) + expect(ebllYesOption).toBeInTheDocument() + expect(ebllYesOption).not.toBeChecked() + expect(ebllNoOption).toBeInTheDocument() + expect(ebllNoOption).toBeChecked() + }) }) diff --git a/sites/partners/__tests__/pages/listings/[id]/index.test.tsx b/sites/partners/__tests__/pages/listings/[id]/index.test.tsx index 5c701454cc..5e2118b478 100644 --- a/sites/partners/__tests__/pages/listings/[id]/index.test.tsx +++ b/sites/partners/__tests__/pages/listings/[id]/index.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable import/no-named-as-default */ import React from "react" import { setupServer } from "msw/lib/node" -import { fireEvent, mockNextRouter, render, within } from "../../../testUtils" +import { fireEvent, mockNextRouter, render, screen, within } from "../../../testUtils" import { ListingContext } from "../../../../src/components/listings/ListingContext" import { jurisdiction, listing, user } from "@bloom-housing/shared-helpers/__tests__/testHelpers" import DetailListingData from "../../../../src/components/listings/PaperListingDetails/sections/DetailListingData" @@ -13,11 +13,13 @@ import DetailPreferences from "../../../../src/components/listings/PaperListingD import { ApplicationAddressTypeEnum, ApplicationMethodsTypeEnum, + EnumListingListingType, FeatureFlagEnum, LanguagesEnum, ListingEventsTypeEnum, ListingsStatusEnum, MultiselectQuestionsApplicationSectionEnum, + MultiselectQuestionsStatusEnum, RegionEnum, ReviewOrderTypeEnum, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" @@ -98,7 +100,7 @@ function mockJurisdictionsHaveFeatureFlagOn( describe("listing data", () => { describe("should display all listing data", () => { it("should display Listing Data section", () => { - const { getByText } = render( + render( { ) - expect(getByText("Listing data")).toBeInTheDocument() - expect(getByText("Listing ID")).toBeInTheDocument() - expect(getByText("Uvbk5qurpB2WI9V6WnNdH")).toBeInTheDocument() - expect(getByText("Date created")).toBeInTheDocument() - expect(getByText("02/03/2025 at 10:13 AM")).toBeInTheDocument() + expect(screen.getByText("Listing data")).toBeInTheDocument() + expect(screen.getByText("Listing ID")).toBeInTheDocument() + expect(screen.getByText("Uvbk5qurpB2WI9V6WnNdH")).toBeInTheDocument() + expect(screen.getByText("Date created")).toBeInTheDocument() + expect(screen.getByText("02/03/2025 at 10:13 AM")).toBeInTheDocument() }) describe("should display Listing Notes section", () => { @@ -122,7 +124,7 @@ describe("listing data", () => { ) it.each(STATUS_OPTIONS)("should hide section for %s status", (status) => { - const { queryByText } = render( + render( { ) - expect(queryByText("Listing notes")).not.toBeInTheDocument() - expect(queryByText("Change request summary")).not.toBeInTheDocument() - expect(queryByText("Test changes")).not.toBeInTheDocument() - expect(queryByText("Request date")).not.toBeInTheDocument() - expect(queryByText("01/10/2025")).not.toBeInTheDocument() - expect(queryByText("Requested by")).not.toBeInTheDocument() - expect(queryByText("John Test")).not.toBeInTheDocument() + expect(screen.queryByText("Listing notes")).not.toBeInTheDocument() + expect(screen.queryByText("Change request summary")).not.toBeInTheDocument() + expect(screen.queryByText("Test changes")).not.toBeInTheDocument() + expect(screen.queryByText("Request date")).not.toBeInTheDocument() + expect(screen.queryByText("01/10/2025")).not.toBeInTheDocument() + expect(screen.queryByText("Requested by")).not.toBeInTheDocument() + expect(screen.queryByText("John Test")).not.toBeInTheDocument() }) it("should show Listing Notes section data - no user defined", () => { - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Listing notes")).toBeInTheDocument() - expect(getByText("Change request summary")).toBeInTheDocument() - expect(getByText("Test changes")).toBeInTheDocument() - expect(getByText("Request date")).toBeInTheDocument() - expect(getByText("01/10/2025")).toBeInTheDocument() - expect(queryByText("Requested by")).not.toBeInTheDocument() - expect(queryByText("John Test")).not.toBeInTheDocument() + expect(screen.getByText("Listing notes")).toBeInTheDocument() + expect(screen.getByText("Change request summary")).toBeInTheDocument() + expect(screen.getByText("Test changes")).toBeInTheDocument() + expect(screen.getByText("Request date")).toBeInTheDocument() + expect(screen.getByText("01/10/2025")).toBeInTheDocument() + expect(screen.queryByText("Requested by")).not.toBeInTheDocument() + expect(screen.queryByText("John Test")).not.toBeInTheDocument() }) it("should show Listing Notes section data - with user defined", () => { - const { getByText } = render( + render( { ) - expect(getByText("Listing notes")).toBeInTheDocument() - expect(getByText("Change request summary")).toBeInTheDocument() - expect(getByText("Test changes")).toBeInTheDocument() - expect(getByText("Request date")).toBeInTheDocument() - expect(getByText("01/10/2025")).toBeInTheDocument() - expect(getByText("Requested by")).toBeInTheDocument() - expect(getByText("John Test")).toBeInTheDocument() + expect(screen.getByText("Listing notes")).toBeInTheDocument() + expect(screen.getByText("Change request summary")).toBeInTheDocument() + expect(screen.getByText("Test changes")).toBeInTheDocument() + expect(screen.getByText("Request date")).toBeInTheDocument() + expect(screen.getByText("01/10/2025")).toBeInTheDocument() + expect(screen.getByText("Requested by")).toBeInTheDocument() + expect(screen.getByText("John Test")).toBeInTheDocument() }) }) - it("should display Listing Intro section", () => { - const { getByText } = render( - - - - ) + describe("should display Listing Intro section", () => { + it("should display Listing Intro section without listing type selection", () => { + render( + + + + ) + + expect(screen.getByText("Listing intro")).toBeInTheDocument() + expect(screen.getByText("Listing name")).toBeInTheDocument() + expect(screen.getByText("Archer Studios")).toBeInTheDocument() + expect(screen.getByText("Jurisdiction")).toBeInTheDocument() + expect(screen.getByText("San Jose")).toBeInTheDocument() + expect(screen.getByText("Housing developer")).toBeInTheDocument() + expect(screen.getByText("Charities Housing")).toBeInTheDocument() + + expect(screen.queryByText("What kind of listing is this?")).not.toBeInTheDocument() + expect(screen.queryByText("Regulated")).not.toBeInTheDocument() + expect(screen.queryByText("Non-regulated")).not.toBeInTheDocument() + expect( + screen.queryByText("Has this property received HUD EBLL clearance?") + ).not.toBeInTheDocument() + }) + + it("should display Listing Intro for regulated listing", () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + expect(screen.getByText("What kind of listing is this?")).toBeInTheDocument() + expect(screen.getByText("Regulated")).toBeInTheDocument() + expect(screen.queryByText("Non-regulated")).not.toBeInTheDocument() + expect( + screen.queryByText("Has this property received HUD EBLL clearance?") + ).not.toBeInTheDocument() + }) - expect(getByText("Listing intro")).toBeInTheDocument() - expect(getByText("Listing name")).toBeInTheDocument() - expect(getByText("Archer Studios")).toBeInTheDocument() - expect(getByText("Jurisdiction")).toBeInTheDocument() - expect(getByText("San Jose")).toBeInTheDocument() - expect(getByText("Housing developer")).toBeInTheDocument() - expect(getByText("Charities Housing")).toBeInTheDocument() + it("should display Listing Intro for non-regulated listing", () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + expect(screen.getByText("What kind of listing is this?")).toBeInTheDocument() + expect(screen.queryByText("Regulated")).not.toBeInTheDocument() + expect(screen.getByText("Non-regulated")).toBeInTheDocument() + expect( + screen.getByText("Has this property received HUD EBLL clearance?") + ).toBeInTheDocument() + }) }) describe("should display Lisiting Photo section", () => { it("should display section with missing data", () => { - const { getByText, queryByText, queryByRole } = render( + render( { ) - expect(getByText("Listing photo")).toBeInTheDocument() - expect(getByText("None")).toBeInTheDocument() - expect(queryByText("Preview")).not.toBeInTheDocument() - expect(queryByText("Primary")).not.toBeInTheDocument() - expect(queryByText("Primary photo")).not.toBeInTheDocument() - expect(queryByRole("img")).not.toBeInTheDocument() + expect(screen.getByText("Listing photo")).toBeInTheDocument() + expect(screen.getByText("None")).toBeInTheDocument() + expect(screen.queryByText("Preview")).not.toBeInTheDocument() + expect(screen.queryByText("Primary")).not.toBeInTheDocument() + expect(screen.queryByText("Primary photo")).not.toBeInTheDocument() + expect(screen.queryByRole("img")).not.toBeInTheDocument() }) it("should display Lisiting Photo section data", () => { - const { getByText, getAllByRole } = render( + render( { ) - expect(getByText("Listing photo", { selector: "h2" })).toBeInTheDocument() - expect(getByText("Preview")).toBeInTheDocument() - expect(getByText("Primary")).toBeInTheDocument() - expect(getByText("Primary photo")).toBeInTheDocument() - const listingImages = getAllByRole("img") + expect(screen.getByText("Listing photo", { selector: "h2" })).toBeInTheDocument() + expect(screen.getByText("Preview")).toBeInTheDocument() + expect(screen.getByText("Primary")).toBeInTheDocument() + expect(screen.getByText("Primary photo")).toBeInTheDocument() + const listingImages = screen.getAllByRole("img") expect(listingImages).toHaveLength(2) listingImages.forEach((imageElement) => { expect(imageElement).toHaveAttribute("src", "asset_file_id") @@ -275,75 +342,77 @@ describe("listing data", () => { }) }) - it("should display Building Details section - without region", () => { - const { getByText, queryByText } = render( - - - - ) - - expect(getByText("Building details")).toBeInTheDocument() - expect(getByText("Building address")).toBeInTheDocument() - expect(getByText("Street address")).toBeInTheDocument() - expect(getByText("98 Archer Street")).toBeInTheDocument() - expect(getByText("City")).toBeInTheDocument() - expect(getByText("San Jose")).toBeInTheDocument() - expect(getByText("Longitude")).toBeInTheDocument() - expect(getByText("-121.91071")).toBeInTheDocument() - expect(getByText("State")).toBeInTheDocument() - expect(getByText("CA")).toBeInTheDocument() - expect(getByText("Latitude")).toBeInTheDocument() - expect(getByText("37.36537")).toBeInTheDocument() - expect(getByText("Zip code")).toBeInTheDocument() - expect(getByText("95112")).toBeInTheDocument() - expect(getByText("Neighborhood")).toBeInTheDocument() - expect(getByText("Rosemary Gardens Park")).toBeInTheDocument() - expect(getByText("Year built")).toBeInTheDocument() - expect(getByText("2012")).toBeInTheDocument() - expect(queryByText("Region")).not.toBeInTheDocument() - expect(queryByText("Southwest")).not.toBeInTheDocument() - }) - - it("should display Building Details section - with region", () => { - const { getByText } = render( - - mockJurisdictionsHaveFeatureFlagOn(featureFlag), - }} - > + describe("should display Building Details section", () => { + it("should display Building Details section - without region", () => { + render( - - ) + ) + + expect(screen.getByText("Building details")).toBeInTheDocument() + expect(screen.getByText("Building address")).toBeInTheDocument() + expect(screen.getByText("Street address")).toBeInTheDocument() + expect(screen.getByText("98 Archer Street")).toBeInTheDocument() + expect(screen.getByText("City")).toBeInTheDocument() + expect(screen.getByText("San Jose")).toBeInTheDocument() + expect(screen.getByText("Longitude")).toBeInTheDocument() + expect(screen.getByText("-121.91071")).toBeInTheDocument() + expect(screen.getByText("State")).toBeInTheDocument() + expect(screen.getByText("CA")).toBeInTheDocument() + expect(screen.getByText("Latitude")).toBeInTheDocument() + expect(screen.getByText("37.36537")).toBeInTheDocument() + expect(screen.getByText("Zip code")).toBeInTheDocument() + expect(screen.getByText("95112")).toBeInTheDocument() + expect(screen.getByText("Neighborhood")).toBeInTheDocument() + expect(screen.getByText("Rosemary Gardens Park")).toBeInTheDocument() + expect(screen.getByText("Year built")).toBeInTheDocument() + expect(screen.getByText("2012")).toBeInTheDocument() + expect(screen.queryByText("Region")).not.toBeInTheDocument() + expect(screen.queryByText("Southwest")).not.toBeInTheDocument() + }) + + it("should display Building Details section - with region", () => { + render( + + mockJurisdictionsHaveFeatureFlagOn(featureFlag), + }} + > + + + + + ) - expect(getByText("Building details")).toBeInTheDocument() - expect(getByText("Building address")).toBeInTheDocument() - expect(getByText("Street address")).toBeInTheDocument() - expect(getByText("98 Archer Street")).toBeInTheDocument() - expect(getByText("City")).toBeInTheDocument() - expect(getByText("San Jose")).toBeInTheDocument() - expect(getByText("Longitude")).toBeInTheDocument() - expect(getByText("-121.91071")).toBeInTheDocument() - expect(getByText("State")).toBeInTheDocument() - expect(getByText("CA")).toBeInTheDocument() - expect(getByText("Latitude")).toBeInTheDocument() - expect(getByText("37.36537")).toBeInTheDocument() - expect(getByText("Zip code")).toBeInTheDocument() - expect(getByText("95112")).toBeInTheDocument() - expect(getByText("Neighborhood")).toBeInTheDocument() - expect(getByText("Rosemary Gardens Park")).toBeInTheDocument() - expect(getByText("Year built")).toBeInTheDocument() - expect(getByText("2012")).toBeInTheDocument() - expect(getByText("Region")).toBeInTheDocument() - expect(getByText("Southwest")).toBeInTheDocument() + expect(screen.getByText("Building details")).toBeInTheDocument() + expect(screen.getByText("Building address")).toBeInTheDocument() + expect(screen.getByText("Street address")).toBeInTheDocument() + expect(screen.getByText("98 Archer Street")).toBeInTheDocument() + expect(screen.getByText("City")).toBeInTheDocument() + expect(screen.getByText("San Jose")).toBeInTheDocument() + expect(screen.getByText("Longitude")).toBeInTheDocument() + expect(screen.getByText("-121.91071")).toBeInTheDocument() + expect(screen.getByText("State")).toBeInTheDocument() + expect(screen.getByText("CA")).toBeInTheDocument() + expect(screen.getByText("Latitude")).toBeInTheDocument() + expect(screen.getByText("37.36537")).toBeInTheDocument() + expect(screen.getByText("Zip code")).toBeInTheDocument() + expect(screen.getByText("95112")).toBeInTheDocument() + expect(screen.getByText("Neighborhood")).toBeInTheDocument() + expect(screen.getByText("Rosemary Gardens Park")).toBeInTheDocument() + expect(screen.getByText("Year built")).toBeInTheDocument() + expect(screen.getByText("2012")).toBeInTheDocument() + expect(screen.getByText("Region")).toBeInTheDocument() + expect(screen.getByText("Southwest")).toBeInTheDocument() + }) }) describe("should display Community Type section", () => { it("should display all section data - without disclaimer", () => { - const { getByText } = render( + render( { ) - expect(getByText("Community type")).toBeInTheDocument() - expect(getByText("Reserved community type")).toBeInTheDocument() - expect(getByText("Farmworker housing")).toBeInTheDocument() - expect(getByText("Reserved community description")).toBeInTheDocument() - expect(getByText("Test community description")).toBeInTheDocument() + expect(screen.getByText("Community type")).toBeInTheDocument() + expect(screen.getByText("Reserved community type")).toBeInTheDocument() + expect(screen.getByText("Farmworker housing")).toBeInTheDocument() + expect(screen.getByText("Reserved community description")).toBeInTheDocument() + expect(screen.getByText("Test community description")).toBeInTheDocument() expect( - getByText( + screen.getByText( "Do you want to include a community type disclaimer as the first page of the application?" ) ).toBeInTheDocument() - expect(getByText("No")).toBeInTheDocument() + expect(screen.getByText("No")).toBeInTheDocument() }) it("should display all section data - with disclaimer", () => { - const { getByText } = render( + render( { ) - expect(getByText("Community type")).toBeInTheDocument() - expect(getByText("Reserved community type")).toBeInTheDocument() - expect(getByText("Farmworker housing")).toBeInTheDocument() - expect(getByText("Reserved community description")).toBeInTheDocument() - expect(getByText("Test community description")).toBeInTheDocument() + expect(screen.getByText("Community type")).toBeInTheDocument() + expect(screen.getByText("Reserved community type")).toBeInTheDocument() + expect(screen.getByText("Farmworker housing")).toBeInTheDocument() + expect(screen.getByText("Reserved community description")).toBeInTheDocument() + expect(screen.getByText("Test community description")).toBeInTheDocument() expect( - getByText( + screen.getByText( "Do you want to include a community type disclaimer as the first page of the application?" ) ).toBeInTheDocument() - expect(getByText("Yes")).toBeInTheDocument() - expect(getByText("Test Disclaimer Title")).toBeInTheDocument() - expect(getByText("Test Disclaimer Description")).toBeInTheDocument() + expect(screen.getByText("Yes")).toBeInTheDocument() + expect(screen.getByText("Test Disclaimer Title")).toBeInTheDocument() + expect(screen.getByText("Test Disclaimer Description")).toBeInTheDocument() }) const COMMUNITY_TYPES = [ @@ -452,7 +521,7 @@ describe("listing data", () => { it.each(COMMUNITY_TYPES)(`Should display %s type`, (item) => { const { typeString, dtoField } = item - const { getByText } = render( + render( { ) - expect(getByText(typeString)) + expect(screen.getByText(typeString)) }) }) it("should display missing Listing Units section", () => { - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Do you want to show unit types or individual units?")).toBeInTheDocument() - expect(getByText("Individual units")).toBeInTheDocument() - expect(getByText("What is the listing availability?")).toBeInTheDocument() - expect(getByText("Available units")).toBeInTheDocument() - expect(getByText("None")).toBeInTheDocument() - - expect(queryByText("Unit #")).not.toBeInTheDocument() - expect(queryByText("Unit type")).not.toBeInTheDocument() - expect(queryByText("AMI")).not.toBeInTheDocument() - expect(queryByText("Rent")).not.toBeInTheDocument() - expect(queryByText("SQ FT")).not.toBeInTheDocument() - expect(queryByText("ADA")).not.toBeInTheDocument() expect( - queryByText("Do you accept Section 8 Housing Choice Vouchers?") + screen.getByText("Do you want to show unit types or individual units?") + ).toBeInTheDocument() + expect(screen.getByText("Individual units")).toBeInTheDocument() + expect(screen.getByText("What is the listing availability?")).toBeInTheDocument() + expect(screen.getByText("Available units")).toBeInTheDocument() + expect(screen.getByText("None")).toBeInTheDocument() + + expect(screen.queryByText("Unit #")).not.toBeInTheDocument() + expect(screen.queryByText("Unit type")).not.toBeInTheDocument() + expect(screen.queryByText("AMI")).not.toBeInTheDocument() + expect(screen.queryByText("Rent")).not.toBeInTheDocument() + expect(screen.queryByText("SQ FT")).not.toBeInTheDocument() + expect(screen.queryByText("ADA")).not.toBeInTheDocument() + expect( + screen.queryByText("Do you accept Section 8 Housing Choice Vouchers?") ).not.toBeInTheDocument() - expect(queryByText("No")).not.toBeInTheDocument() + expect(screen.queryByText("No")).not.toBeInTheDocument() }) it("should display Listing Units section", () => { - const { getByText, getAllByText } = render( + render( { ) - expect(getByText("Do you want to show unit types or individual units?")).toBeInTheDocument() - expect(getByText("Individual units")).toBeInTheDocument() - expect(getByText("What is the listing availability?")).toBeInTheDocument() - expect(getByText("Available units")).toBeInTheDocument() - - expect(getByText("Unit #")).toBeInTheDocument() - expect(getByText("Unit type")).toBeInTheDocument() - expect(getByText("AMI")).toBeInTheDocument() - expect(getByText("Rent")).toBeInTheDocument() - expect(getByText("SQ FT")).toBeInTheDocument() - expect(getByText("ADA")).toBeInTheDocument() - - expect(getAllByText(/#[1-9]/i)).toHaveLength(6) - expect(getAllByText("Studio")).toHaveLength(6) - expect(getAllByText("45.0")).toHaveLength(6) - expect(getAllByText("1104.0")).toHaveLength(6) - expect(getAllByText("285")).toHaveLength(6) - expect(getAllByText(/Test ADA_\d{1}/)).toHaveLength(6) - expect(getAllByText("View")).toHaveLength(6) - - expect(getByText("Do you accept Section 8 Housing Choice Vouchers?")).toBeInTheDocument() - expect(getByText("Yes")).toBeInTheDocument() + expect( + screen.getByText("Do you want to show unit types or individual units?") + ).toBeInTheDocument() + expect(screen.getByText("Individual units")).toBeInTheDocument() + expect(screen.getByText("What is the listing availability?")).toBeInTheDocument() + expect(screen.getByText("Available units")).toBeInTheDocument() + + expect(screen.getByText("Unit #")).toBeInTheDocument() + expect(screen.getByText("Unit type")).toBeInTheDocument() + expect(screen.getByText("AMI")).toBeInTheDocument() + expect(screen.getByText("Rent")).toBeInTheDocument() + expect(screen.getByText("SQ FT")).toBeInTheDocument() + expect(screen.getByText("ADA")).toBeInTheDocument() + + expect(screen.getAllByText(/#[1-9]/i)).toHaveLength(6) + expect(screen.getAllByText("Studio")).toHaveLength(6) + expect(screen.getAllByText("45.0")).toHaveLength(6) + expect(screen.getAllByText("1104.0")).toHaveLength(6) + expect(screen.getAllByText("285")).toHaveLength(6) + expect(screen.getAllByText(/Test ADA_\d{1}/)).toHaveLength(6) + expect(screen.getAllByText("View")).toHaveLength(6) + + expect( + screen.getByText("Do you accept Section 8 Housing Choice Vouchers?") + ).toBeInTheDocument() + expect(screen.getByText("Yes")).toBeInTheDocument() }) it("should display missing Housing Preferences section", () => { - const { getByText, queryByText } = render( + render( ) - expect(getByText("Housing preferences")).toBeInTheDocument() - expect(getByText("Active preferences")).toBeInTheDocument() - expect(getByText("None")).toBeInTheDocument() - expect(queryByText("Order")).not.toBeInTheDocument() - expect(queryByText("Name")).not.toBeInTheDocument() - expect(queryByText("Description")).not.toBeInTheDocument() + expect(screen.getByText("Housing preferences")).toBeInTheDocument() + expect(screen.getByText("Active preferences")).toBeInTheDocument() + expect(screen.getByText("None")).toBeInTheDocument() + expect(screen.queryByText("Order")).not.toBeInTheDocument() + expect(screen.queryByText("Name")).not.toBeInTheDocument() + expect(screen.queryByText("Description")).not.toBeInTheDocument() }) it("should display Housing Preferences section", () => { - const { getByText, getAllByText } = render( + render( { ) - expect(getByText("Housing preferences")).toBeInTheDocument() - expect(getByText("Active preferences")).toBeInTheDocument() - expect(getByText("Order")).toBeInTheDocument() - expect(getByText("1")).toBeInTheDocument() - expect(getByText("2")).toBeInTheDocument() - expect(getByText("Name")).toBeInTheDocument() - expect(getAllByText(/Test Name_\d{1}/)).toHaveLength(2) - expect(getByText("Description")).toBeInTheDocument() - expect(getAllByText(/Test Description_\d{1}/)).toHaveLength(2) + expect(screen.getByText("Housing preferences")).toBeInTheDocument() + expect(screen.getByText("Active preferences")).toBeInTheDocument() + expect(screen.getByText("Order")).toBeInTheDocument() + expect(screen.getByText("1")).toBeInTheDocument() + expect(screen.getByText("2")).toBeInTheDocument() + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getAllByText(/Test Name_\d{1}/)).toHaveLength(2) + expect(screen.getByText("Description")).toBeInTheDocument() + expect(screen.getAllByText(/Test Description_\d{1}/)).toHaveLength(2) }) it("should display Housing Programs section", () => { - const { getByText, getAllByText } = render( + render( { text: "Test Program Name_1", description: "Test Program Description_1", applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, }, }, { @@ -642,6 +718,7 @@ describe("listing data", () => { text: "Test Program Name_2", description: "Test Program Description_2", applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, }, }, ], @@ -651,19 +728,19 @@ describe("listing data", () => { ) - expect(getByText("Housing programs")).toBeInTheDocument() - expect(getByText("Active programs")).toBeInTheDocument() - expect(getByText("Order")).toBeInTheDocument() - expect(getByText("1")).toBeInTheDocument() - expect(getByText("2")).toBeInTheDocument() - expect(getByText("Name")).toBeInTheDocument() - expect(getAllByText(/Test Program Name_\d{1}/)).toHaveLength(2) - expect(getByText("Description")).toBeInTheDocument() - expect(getAllByText(/Test Program Description_\d{1}/)).toHaveLength(2) + expect(screen.getByText("Housing programs")).toBeInTheDocument() + expect(screen.getByText("Active programs")).toBeInTheDocument() + expect(screen.getByText("Order")).toBeInTheDocument() + expect(screen.getByText("1")).toBeInTheDocument() + expect(screen.getByText("2")).toBeInTheDocument() + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getAllByText(/Test Program Name_\d{1}/)).toHaveLength(2) + expect(screen.getByText("Description")).toBeInTheDocument() + expect(screen.getAllByText(/Test Program Description_\d{1}/)).toHaveLength(2) }) it("should display Additional Fees section", () => { - const { getByText } = render( + render( { ) - expect(getByText("Additional fees")).toBeInTheDocument() - expect(getByText("Application fee")).toBeInTheDocument() - expect(getByText("30.0")).toBeInTheDocument() - expect(getByText("Deposit max")).toBeInTheDocument() - expect(getByText("1000")).toBeInTheDocument() - expect(getByText("Deposit helper text")).toBeInTheDocument() - expect(getByText("Test Deposit Helper Text")).toBeInTheDocument() - expect(getByText("Deposit min")).toBeInTheDocument() - expect(getByText("1140.0")).toBeInTheDocument() - expect(getByText("Costs not included")).toBeInTheDocument() + expect(screen.getByText("Additional fees")).toBeInTheDocument() + expect(screen.getByText("Application fee")).toBeInTheDocument() + expect(screen.getByText("30.0")).toBeInTheDocument() + expect(screen.getByText("Deposit helper text")).toBeInTheDocument() + expect(screen.getByText("Test Deposit Helper Text")).toBeInTheDocument() + expect(screen.getByText("Costs not included")).toBeInTheDocument() expect( - getByText( + screen.getByText( "Resident responsible for PG&E, internet and phone. Owner pays for water, trash, and sewage." ) ).toBeInTheDocument() @@ -696,7 +769,7 @@ describe("listing data", () => { describe("should display Building Features section", () => { it("should display data with no accessibility features", () => { - const { getByText } = render( + render( { ) - expect(getByText("Building features")).toBeInTheDocument() - expect(getByText("Property amenities")).toBeInTheDocument() + expect(screen.getByText("Building features")).toBeInTheDocument() + expect(screen.getByText("Property amenities")).toBeInTheDocument() expect( - getByText( + screen.getByText( "Community Room, Laundry Room, Assigned Parking, Bike Storage, Roof Top Garden, Part-time Resident Service Coordinator" ) ).toBeInTheDocument() - expect(getByText("Unit amenities")).toBeInTheDocument() - expect(getByText("Dishwasher")).toBeInTheDocument() - expect(getByText("Additional accessibility")).toBeInTheDocument() + expect(screen.getByText("Unit amenities")).toBeInTheDocument() + expect(screen.getByText("Dishwasher")).toBeInTheDocument() + expect(screen.getByText("Additional accessibility")).toBeInTheDocument() expect( - getByText( + screen.getByText( "There is a total of 5 ADA units in the complex, all others are adaptable. Exterior Wheelchair ramp (front entry)" ) ).toBeInTheDocument() - expect(getByText("Smoking policy")).toBeInTheDocument() - expect(getByText("Non-smoking building")).toBeInTheDocument() - expect(getByText("Pets policy")).toBeInTheDocument() + expect(screen.getByText("Smoking policy")).toBeInTheDocument() + expect(screen.getByText("Non-smoking building")).toBeInTheDocument() + expect(screen.getByText("Pets policy")).toBeInTheDocument() expect( - getByText( + screen.getByText( "No pets allowed. Accommodation animals may be granted to persons with disabilities via a reasonable accommodation request." ) ).toBeInTheDocument() - expect(getByText("Services offered")).toBeInTheDocument() - expect(getByText("Professional Help")).toBeInTheDocument() + expect(screen.getByText("Services offered")).toBeInTheDocument() + expect(screen.getByText("Professional Help")).toBeInTheDocument() }) it("should display accessibility features", () => { @@ -742,7 +815,7 @@ describe("listing data", () => { }) ) - const { getByText } = render( + render( { ) - expect(getByText("Elevator")).toBeInTheDocument() - expect(getByText("Wheelchair ramp")).toBeInTheDocument() - expect(getByText("Service animals allowed")).toBeInTheDocument() - expect(getByText("Accessible parking spots")).toBeInTheDocument() - expect(getByText("Parking on site")).toBeInTheDocument() - expect(getByText("In-unit washer/dryer")).toBeInTheDocument() - expect(getByText("Laundry in building")).toBeInTheDocument() - expect(getByText("Barrier-free (no-step) property entrance")).toBeInTheDocument() - expect(getByText("Roll-in showers")).toBeInTheDocument() - expect(getByText("Grab bars in bathrooms")).toBeInTheDocument() - expect(getByText("Heating in unit")).toBeInTheDocument() - expect(getByText("AC in unit")).toBeInTheDocument() - expect(getByText("Units for those with hearing disabilities")).toBeInTheDocument() - expect(getByText("Units for those with visual disabilities")).toBeInTheDocument() - expect(getByText("Units for those with mobility disabilities")).toBeInTheDocument() - expect(getByText("Lowered cabinets and countertops")).toBeInTheDocument() - expect(getByText("Lowered light switches")).toBeInTheDocument() - expect(getByText("Wide unit doorways for wheelchairs")).toBeInTheDocument() - expect(getByText("Barrier-free bathrooms")).toBeInTheDocument() - expect(getByText("Barrier-free (no-step) unit entrances")) + expect(screen.getByText("Elevator")).toBeInTheDocument() + expect(screen.getByText("Wheelchair ramp")).toBeInTheDocument() + expect(screen.getByText("Service animals allowed")).toBeInTheDocument() + expect(screen.getByText("Accessible parking spots")).toBeInTheDocument() + expect(screen.getByText("Parking on site")).toBeInTheDocument() + expect(screen.getByText("In-unit washer/dryer")).toBeInTheDocument() + expect(screen.getByText("Laundry in building")).toBeInTheDocument() + expect(screen.getByText("Barrier-free (no-step) property entrance")).toBeInTheDocument() + expect(screen.getByText("Roll-in showers")).toBeInTheDocument() + expect(screen.getByText("Grab bars in bathrooms")).toBeInTheDocument() + expect(screen.getByText("Heating in unit")).toBeInTheDocument() + expect(screen.getByText("AC in unit")).toBeInTheDocument() + expect(screen.getByText("Units for those with hearing disabilities")).toBeInTheDocument() + expect(screen.getByText("Units for those with visual disabilities")).toBeInTheDocument() + expect(screen.getByText("Units for those with mobility disabilities")).toBeInTheDocument() + expect(screen.getByText("Lowered cabinets and countertops")).toBeInTheDocument() + expect(screen.getByText("Lowered light switches")).toBeInTheDocument() + expect(screen.getByText("Wide unit doorways for wheelchairs")).toBeInTheDocument() + expect(screen.getByText("Barrier-free bathrooms")).toBeInTheDocument() + expect(screen.getByText("Barrier-free (no-step) unit entrances")) }) }) describe("should display Additional Eligibility Rules section", () => { it("should display data with selection criteria", () => { - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Additional eligibility rules")).toBeInTheDocument() - expect(getByText("Credit history")).toBeInTheDocument() + expect(screen.getByText("Additional eligibility rules")).toBeInTheDocument() + expect(screen.getByText("Credit history")).toBeInTheDocument() expect( // Look only for part of the text to verify that content rendered properly - getByText( + screen.getByText( /Applications will be rated on a score system for housing. An applicant's score may be impacted by negative tenant peformance information provided to the credit reporting agency./ ) ).toBeInTheDocument() - expect(getByText("Rental history")).toBeInTheDocument() + expect(screen.getByText("Rental history")).toBeInTheDocument() expect( // Look only for part of the text to verify that content rendered properly - getByText(/Two years of rental history will be verified with all applicable landlords./) + screen.getByText( + /Two years of rental history will be verified with all applicable landlords./ + ) ).toBeInTheDocument() - expect(getByText("Criminal background")).toBeInTheDocument() + expect(screen.getByText("Criminal background")).toBeInTheDocument() expect( // Look only for part of the text to verify that content rendered properly - getByText(/A criminal background investigation will be obtained on each applicant./) + screen.getByText( + /A criminal background investigation will be obtained on each applicant./ + ) ).toBeInTheDocument() - expect(getByText("Rental assistance")).toBeInTheDocument() - expect(getByText("Custom rental assistance")).toBeInTheDocument() - expect(getByText("Building selection criteria")).toBeInTheDocument() - expect(getByText("URL")).toBeInTheDocument() + expect(screen.getByText("Rental assistance")).toBeInTheDocument() + expect(screen.getByText("Custom rental assistance")).toBeInTheDocument() + expect(screen.getByText("Building selection criteria")).toBeInTheDocument() + expect(screen.getByText("URL")).toBeInTheDocument() expect( - getByText("Tenant Selection Criteria will be available to all applicants upon request.") + screen.getByText( + "Tenant Selection Criteria will be available to all applicants upon request." + ) ).toBeInTheDocument() - expect(queryByText("Preview")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Preview")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() }) it("should display selection criteria file", async () => { - const { getByText, findByRole } = render( + render( { ) - expect(getByText("Preview")).toBeInTheDocument() - expect(getByText("File name")).toBeInTheDocument() - expect(getByText("example_file.pdf")).toBeInTheDocument() - expect(getByText("Preview")).toBeInTheDocument() + expect(screen.getByText("Preview")).toBeInTheDocument() + expect(screen.getByText("File name")).toBeInTheDocument() + expect(screen.getByText("example_file.pdf")).toBeInTheDocument() + expect(screen.getByText("Preview")).toBeInTheDocument() - const previewImage = await findByRole("img") + const previewImage = await screen.findByRole("img") expect(previewImage).toBeInTheDocument() expect(previewImage).toHaveAttribute( "src", @@ -883,32 +962,188 @@ describe("listing data", () => { }) }) - it("should display Additional Details section", () => { - const { getByText } = render( - - - - ) + describe("should display Additional Details section", () => { + it("should display Additional Details section for regulated listings", () => { + render( + + + + ) - expect(getByText("Required documents")).toBeInTheDocument() - expect(getByText("Completed application and government issued IDs")).toBeInTheDocument() - expect(getByText("Important program rules")).toBeInTheDocument() - expect( - getByText( - "Applicants must adhere to minimum & maximum income limits. Tenant Selection Criteria applies." + expect(screen.getByText("Required documents")).toBeInTheDocument() + expect( + screen.getByText("Completed application and government issued IDs") + ).toBeInTheDocument() + expect(screen.getByText("Important program rules")).toBeInTheDocument() + expect( + screen.getByText( + "Applicants must adhere to minimum & maximum income limits. Tenant Selection Criteria applies." + ) + ).toBeInTheDocument() + expect(screen.getByText("Special notes")).toBeInTheDocument() + expect(screen.getByText("Special notes description")).toBeInTheDocument() + }) + + it("shoudld display Additional Details section for non-regulated listings - show all documents options", () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + ) - ).toBeInTheDocument() - expect(getByText("Special notes")).toBeInTheDocument() - expect(getByText("Special notes description")).toBeInTheDocument() + + const requiredDocumentsListTitle = screen.getByText("Required documents") + expect(requiredDocumentsListTitle).toBeInTheDocument() + const requiredDocumentsListContainer = requiredDocumentsListTitle.parentElement + + expect( + within(requiredDocumentsListContainer).getByText("Social Security card") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText("Current landlord reference") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Birth Certificate (all household members 18+)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText("Previous landlord reference") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Government-issued ID (all household members 18+)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Proof of Assets (bank statements, etc.)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Proof of household income (check stubs, W-2, etc.)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Immigration/Residency documents (green card, etc.)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText("Proof of Custody/Guardianship") + ).toBeInTheDocument() + + expect(screen.getByText("Required documents (Additional Info)")).toBeInTheDocument() + }) + + it("shoudld display Additional Details section for non-regulated listings - show partial documents options", () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + const requiredDocumentsListTitle = screen.getByText("Required documents") + expect(requiredDocumentsListTitle).toBeInTheDocument() + const requiredDocumentsListContainer = requiredDocumentsListTitle.parentElement + + expect( + within(requiredDocumentsListContainer).getByText("Social Security card") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText("Current landlord reference") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Birth Certificate (all household members 18+)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText("Previous landlord reference") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).queryByText( + "Government-issued ID (all household members 18+)" + ) + ).not.toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).queryByText( + "Proof of Assets (bank statements, etc.)" + ) + ).not.toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).queryByText( + "Proof of household income (check stubs, W-2, etc.)" + ) + ).not.toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).queryByText( + "Immigration/Residency documents (green card, etc.)" + ) + ).not.toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).queryByText("Proof of Custody/Guardianship") + ).not.toBeInTheDocument() + + expect(screen.getByText("Required documents (Additional Info)")).toBeInTheDocument() + }) }) describe("should display Rankings & Results section", () => { it("should display data for waitlist review order typy without lottery event", () => { - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Rankings & results")).toBeInTheDocument() - expect(getByText("Do you want to show a waitlist size?")).toBeInTheDocument() - expect(getByText("Yes")).toBeInTheDocument() - expect(getByText("Number of openings")).toBeInTheDocument() - expect(getByText("Tell the applicant what to expect from the process")).toBeInTheDocument() + expect(screen.getByText("Rankings & results")).toBeInTheDocument() + expect(screen.getByText("Do you want to show a waitlist size?")).toBeInTheDocument() + expect(screen.getByText("Yes")).toBeInTheDocument() + expect(screen.getByText("Number of openings")).toBeInTheDocument() + expect( + screen.getByText("Tell the applicant what to expect from the process") + ).toBeInTheDocument() expect( - getByText( + screen.getByText( "Applicant will be contacted. All info will be verified. Be prepared if chosen." ) ).toBeInTheDocument() expect( - queryByText("How is the application review order determined?") + screen.queryByText("How is the application review order determined?") ).not.toBeInTheDocument() - expect(queryByText("Lottery")).not.toBeInTheDocument() - expect(queryByText("First come first serve")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery")).not.toBeInTheDocument() + expect(screen.queryByText("First come first serve")).not.toBeInTheDocument() expect( - queryByText("Will the lottery be run in the partner portal?") + screen.queryByText("Will the lottery be run in the partner portal?") ).not.toBeInTheDocument() - expect(queryByText("When will the lottery be run?")).not.toBeInTheDocument() - expect(queryByText("Lottery start time")).not.toBeInTheDocument() - expect(queryByText("Lottery end time")).not.toBeInTheDocument() - expect(queryByText("Lottery date notes")).not.toBeInTheDocument() + expect(screen.queryByText("When will the lottery be run?")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery start time")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery end time")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery date notes")).not.toBeInTheDocument() }) it("should display data for first come first serve review order typy without lottery event", () => { - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Rankings & results")).toBeInTheDocument() - expect(getByText("How is the application review order determined?")).toBeInTheDocument() - expect(getByText("First come first serve")).toBeInTheDocument() - expect(getByText("Tell the applicant what to expect from the process")).toBeInTheDocument() + expect(screen.getByText("Rankings & results")).toBeInTheDocument() + expect( + screen.getByText("How is the application review order determined?") + ).toBeInTheDocument() + expect(screen.getByText("First come first serve")).toBeInTheDocument() expect( - getByText( + screen.getByText("Tell the applicant what to expect from the process") + ).toBeInTheDocument() + expect( + screen.getByText( "Applicant will be contacted. All info will be verified. Be prepared if chosen." ) ).toBeInTheDocument() - expect(queryByText("Do you want to show a waitlist size?")).not.toBeInTheDocument() - expect(queryByText("Yes")).not.toBeInTheDocument() - expect(queryByText("Number of Openings")).not.toBeInTheDocument() - expect(queryByText("Lottery")).not.toBeInTheDocument() + expect(screen.queryByText("Do you want to show a waitlist size?")).not.toBeInTheDocument() + expect(screen.queryByText("Yes")).not.toBeInTheDocument() + expect(screen.queryByText("Number of Openings")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery")).not.toBeInTheDocument() expect( - queryByText("Will the lottery be run in the partner portal?") + screen.queryByText("Will the lottery be run in the partner portal?") ).not.toBeInTheDocument() - expect(queryByText("When will the lottery be run?")).not.toBeInTheDocument() - expect(queryByText("Lottery start time")).not.toBeInTheDocument() - expect(queryByText("Lottery end time")).not.toBeInTheDocument() - expect(queryByText("Lottery date notes")).not.toBeInTheDocument() + expect(screen.queryByText("When will the lottery be run?")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery start time")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery end time")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery date notes")).not.toBeInTheDocument() }) it("should display data for lottery serve review order typy with lottery event", () => { process.env.showLottery = "true" - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Rankings & results")).toBeInTheDocument() - expect(getByText("How is the application review order determined?")).toBeInTheDocument() - expect(getByText("Lottery")).toBeInTheDocument() - expect(getByText("Will the lottery be run in the partner portal?")).toBeInTheDocument() - expect(getByText("No")).toBeInTheDocument() - expect(getByText("When will the lottery be run?")).toBeInTheDocument() - expect(getByText("02/18/2024")).toBeInTheDocument() - expect(getByText("Lottery start time")).toBeInTheDocument() - expect(getByText("10:30 AM")).toBeInTheDocument() - expect(getByText("Lottery end time")).toBeInTheDocument() - expect(getByText("12:15 PM")).toBeInTheDocument() - expect(getByText("Lottery date notes")).toBeInTheDocument() - expect(getByText("Test lottery note")).toBeInTheDocument() - expect(getByText("Tell the applicant what to expect from the process")).toBeInTheDocument() + expect(screen.getByText("Rankings & results")).toBeInTheDocument() + expect( + screen.getByText("How is the application review order determined?") + ).toBeInTheDocument() + expect(screen.getByText("Lottery")).toBeInTheDocument() + expect( + screen.getByText("Will the lottery be run in the partner portal?") + ).toBeInTheDocument() + expect(screen.getByText("No")).toBeInTheDocument() + expect(screen.getByText("When will the lottery be run?")).toBeInTheDocument() + expect(screen.getByText("02/18/2024")).toBeInTheDocument() + expect(screen.getByText("Lottery start time")).toBeInTheDocument() + expect(screen.getByText("10:30 AM")).toBeInTheDocument() + expect(screen.getByText("Lottery end time")).toBeInTheDocument() + expect(screen.getByText("12:15 PM")).toBeInTheDocument() + expect(screen.getByText("Lottery date notes")).toBeInTheDocument() + expect(screen.getByText("Test lottery note")).toBeInTheDocument() expect( - getByText( + screen.getByText("Tell the applicant what to expect from the process") + ).toBeInTheDocument() + expect( + screen.getByText( "Applicant will be contacted. All info will be verified. Be prepared if chosen." ) ).toBeInTheDocument() - expect(queryByText("Do you want to show a waitlist size?")).not.toBeInTheDocument() - expect(queryByText("Yes")).not.toBeInTheDocument() - expect(queryByText("Number of openings")).not.toBeInTheDocument() - expect(queryByText("First come first serve")).not.toBeInTheDocument() + expect(screen.queryByText("Do you want to show a waitlist size?")).not.toBeInTheDocument() + expect(screen.queryByText("Yes")).not.toBeInTheDocument() + expect(screen.queryByText("Number of openings")).not.toBeInTheDocument() + expect(screen.queryByText("First come first serve")).not.toBeInTheDocument() }) }) it("should display Leasing Agent section", () => { - const { getByText } = render( + render( { ) - expect(getByText("Leasing agent")).toBeInTheDocument() - expect(getByText("Leasing agent name")).toBeInTheDocument() - expect(getByText("Marisela Baca")).toBeInTheDocument() - expect(getByText("Email")).toBeInTheDocument() - expect(getByText("mbaca@charitieshousing.org")).toBeInTheDocument() - expect(getByText("Phone")).toBeInTheDocument() - expect(getByText("(408) 217-8562")).toBeInTheDocument() - expect(getByText("Leasing agent title")).toBeInTheDocument() - expect(getByText("Pro Agent")).toBeInTheDocument() - expect(getByText("Office hours")).toBeInTheDocument() - expect(getByText("Monday, Tuesday & Friday, 9:00AM - 5:00PM")).toBeInTheDocument() - expect(getByText("Leasing agent address")).toBeInTheDocument() - expect(getByText("Street address or PO box")).toBeInTheDocument() - expect(getByText("98 Archer Street")).toBeInTheDocument() - expect(getByText("Apt or unit #")).toBeInTheDocument() - expect(getByText("#12")).toBeInTheDocument() - expect(getByText("City")).toBeInTheDocument() - expect(getByText("San Jose")).toBeInTheDocument() - expect(getByText("State")).toBeInTheDocument() - expect(getByText("CA")).toBeInTheDocument() - expect(getByText("Zip code")).toBeInTheDocument() - expect(getByText("95112")).toBeInTheDocument() + expect(screen.getByText("Leasing agent")).toBeInTheDocument() + expect(screen.getByText("Leasing agent name")).toBeInTheDocument() + expect(screen.getByText("Marisela Baca")).toBeInTheDocument() + expect(screen.getByText("Email")).toBeInTheDocument() + expect(screen.getByText("mbaca@charitieshousing.org")).toBeInTheDocument() + expect(screen.getByText("Phone")).toBeInTheDocument() + expect(screen.getByText("(408) 217-8562")).toBeInTheDocument() + expect(screen.getByText("Leasing agent title")).toBeInTheDocument() + expect(screen.getByText("Pro Agent")).toBeInTheDocument() + expect(screen.getByText("Office hours")).toBeInTheDocument() + expect(screen.getByText("Monday, Tuesday & Friday, 9:00AM - 5:00PM")).toBeInTheDocument() + expect(screen.getByText("Leasing agent address")).toBeInTheDocument() + expect(screen.getByText("Street address or PO box")).toBeInTheDocument() + expect(screen.getByText("98 Archer Street")).toBeInTheDocument() + expect(screen.getByText("Apt or unit #")).toBeInTheDocument() + expect(screen.getByText("#12")).toBeInTheDocument() + expect(screen.getByText("City")).toBeInTheDocument() + expect(screen.getByText("San Jose")).toBeInTheDocument() + expect(screen.getByText("State")).toBeInTheDocument() + expect(screen.getByText("CA")).toBeInTheDocument() + expect(screen.getByText("Zip code")).toBeInTheDocument() + expect(screen.getByText("95112")).toBeInTheDocument() }) describe("should display Application Types section", () => { it("should display section with missing data", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getByText("Paper applications")).toBeInTheDocument() - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("n/a")).toHaveLength(3) - expect(queryByText("Common digital application")).not.toBeInTheDocument() - expect(queryByText("Referral contact phone")).not.toBeInTheDocument() - expect(queryByText("Referral summary")).not.toBeInTheDocument() - expect(queryByText("Custom online application URL")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() - expect(queryByText("Language")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Paper applications")).toBeInTheDocument() + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("n/a")).toHaveLength(3) + expect(screen.queryByText("Common digital application")).not.toBeInTheDocument() + expect(screen.queryByText("Referral contact phone")).not.toBeInTheDocument() + expect(screen.queryByText("Referral summary")).not.toBeInTheDocument() + expect(screen.queryByText("Custom online application URL")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Language")).not.toBeInTheDocument() }) it("should display section data - for internal application", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getByText("Common digital application")).toBeInTheDocument() - expect(getByText("Paper applications")).toBeInTheDocument() - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("Yes")).toHaveLength(4) - expect(queryByText("Referral contact phone")).not.toBeInTheDocument() - expect(queryByText("Referral summary")).not.toBeInTheDocument() - expect(queryByText("Custom online application URL")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() - expect(queryByText("Language")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Common digital application")).toBeInTheDocument() + expect(screen.getByText("Paper applications")).toBeInTheDocument() + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("Yes")).toHaveLength(4) + expect(screen.queryByText("Referral contact phone")).not.toBeInTheDocument() + expect(screen.queryByText("Referral summary")).not.toBeInTheDocument() + expect(screen.queryByText("Custom online application URL")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Language")).not.toBeInTheDocument() }) it("should display section data - for external application", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getByText("Common digital application")).toBeInTheDocument() - expect(getByText("Paper applications")).toBeInTheDocument() - expect(getByText("Custom online application URL")).toBeInTheDocument() - expect(getByText("Test reference")).toBeInTheDocument() - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("No")).toHaveLength(4) - expect(queryByText("Referral contact phone")).not.toBeInTheDocument() - expect(queryByText("Referral summary")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() - expect(queryByText("Language")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Common digital application")).toBeInTheDocument() + expect(screen.getByText("Paper applications")).toBeInTheDocument() + expect(screen.getByText("Custom online application URL")).toBeInTheDocument() + expect(screen.getByText("Test reference")).toBeInTheDocument() + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("No")).toHaveLength(4) + expect(screen.queryByText("Referral contact phone")).not.toBeInTheDocument() + expect(screen.queryByText("Referral summary")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Language")).not.toBeInTheDocument() }) it("should display section data - for referral application", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getByText("Paper applications")).toBeInTheDocument() - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("No")).toHaveLength(3) - expect(getByText("Referral contact phone")).toBeInTheDocument() - expect(getByText("(509) 786-4500")).toBeInTheDocument() - expect(getByText("Referral summary")).toBeInTheDocument() - expect(getByText("Test Referral Summary")).toBeInTheDocument() - expect(queryByText("Common digital application")).not.toBeInTheDocument() - expect(queryByText("Custom online application URL")).not.toBeInTheDocument() - expect(queryByText("Test Reference")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() - expect(queryByText("Language")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Paper applications")).toBeInTheDocument() + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("No")).toHaveLength(3) + expect(screen.getByText("Referral contact phone")).toBeInTheDocument() + expect(screen.getByText("(509) 786-4500")).toBeInTheDocument() + expect(screen.getByText("Referral summary")).toBeInTheDocument() + expect(screen.getByText("Test Referral Summary")).toBeInTheDocument() + expect(screen.queryByText("Common digital application")).not.toBeInTheDocument() + expect(screen.queryByText("Custom online application URL")).not.toBeInTheDocument() + expect(screen.queryByText("Test Reference")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Language")).not.toBeInTheDocument() }) it("should display section data - for paper application", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getAllByText("Paper applications")).toHaveLength(2) - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("No")).toHaveLength(3) - expect(getByText("File name")).toBeInTheDocument() - expect(getByText("Language")).toBeInTheDocument() - expect(getByText("English")).toBeInTheDocument() - expect(getByText("Español")).toBeInTheDocument() - expect(getAllByText(/asset_\d_file_id.pdf/)).toHaveLength(2) - - expect(queryByText("Referral contact phone")).not.toBeInTheDocument() - expect(queryByText("Referral summary")).not.toBeInTheDocument() - expect(queryByText("Common digital application")).not.toBeInTheDocument() - expect(queryByText("Custom online application URL")).not.toBeInTheDocument() - expect(queryByText("Test Reference")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getAllByText("Paper applications")).toHaveLength(2) + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("No")).toHaveLength(3) + expect(screen.getByText("File name")).toBeInTheDocument() + expect(screen.getByText("Language")).toBeInTheDocument() + expect(screen.getByText("English")).toBeInTheDocument() + expect(screen.getByText("Español")).toBeInTheDocument() + expect(screen.getAllByText(/asset_\d_file_id.pdf/)).toHaveLength(2) + + expect(screen.queryByText("Referral contact phone")).not.toBeInTheDocument() + expect(screen.queryByText("Referral summary")).not.toBeInTheDocument() + expect(screen.queryByText("Common digital application")).not.toBeInTheDocument() + expect(screen.queryByText("Custom online application URL")).not.toBeInTheDocument() + expect(screen.queryByText("Test Reference")).not.toBeInTheDocument() }) it("should hide digital application choice when disable flag is on", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getByText("Paper applications")).toBeInTheDocument() - expect(getByText("Custom online application URL")).toBeInTheDocument() - expect(getByText("https://example.com/application")).toBeInTheDocument() - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("No")).toHaveLength(3) - expect(queryByText("Common digital application")).not.toBeInTheDocument() - expect(queryByText("Referral contact phone")).not.toBeInTheDocument() - expect(queryByText("Referral summary")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() - expect(queryByText("Language")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Paper applications")).toBeInTheDocument() + expect(screen.getByText("Custom online application URL")).toBeInTheDocument() + expect(screen.getByText("https://example.com/application")).toBeInTheDocument() + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("No")).toHaveLength(3) + expect(screen.queryByText("Common digital application")).not.toBeInTheDocument() + expect(screen.queryByText("Referral contact phone")).not.toBeInTheDocument() + expect(screen.queryByText("Referral summary")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Language")).not.toBeInTheDocument() }) }) describe("should display Application Address section", () => { it("should display section with mising data", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application address")).toBeInTheDocument() - expect(getByText("Can applications be mailed in?")).toBeInTheDocument() - expect(getByText("Can applications be picked up?")).toBeInTheDocument() - expect(getByText("Can applications be dropped off?")).toBeInTheDocument() - expect(getByText("Are postmarks considered?")).toBeInTheDocument() - expect(getByText("Additional application submission notes")).toBeInTheDocument() - expect(getAllByText("No")).toHaveLength(4) - expect(getAllByText("None")).toHaveLength(1) - - expect(queryByText("Where can applications be mailed in?")).not.toBeInTheDocument() - expect(queryByText("Leasing agent address")).not.toBeInTheDocument() - expect(queryByText("Mailing address")).not.toBeInTheDocument() - expect(queryByText("Where are applications picked up?")).not.toBeInTheDocument() - expect(queryByText("Pickup address")).not.toBeInTheDocument() - expect(queryByText("Office hours")).not.toBeInTheDocument() - expect(queryByText("Where are applications dropped off?")).not.toBeInTheDocument() - expect(queryByText("Drop off address")).not.toBeInTheDocument() - expect(queryByText("Received by date")).not.toBeInTheDocument() - expect(queryByText("Received by time")).not.toBeInTheDocument() + expect(screen.getByText("Application address")).toBeInTheDocument() + expect(screen.getByText("Can applications be mailed in?")).toBeInTheDocument() + expect(screen.getByText("Can applications be picked up?")).toBeInTheDocument() + expect(screen.getByText("Can applications be dropped off?")).toBeInTheDocument() + expect(screen.getByText("Are postmarks considered?")).toBeInTheDocument() + expect(screen.getByText("Additional application submission notes")).toBeInTheDocument() + expect(screen.getAllByText("No")).toHaveLength(4) + expect(screen.getAllByText("None")).toHaveLength(1) + + expect(screen.queryByText("Where can applications be mailed in?")).not.toBeInTheDocument() + expect(screen.queryByText("Leasing agent address")).not.toBeInTheDocument() + expect(screen.queryByText("Mailing address")).not.toBeInTheDocument() + expect(screen.queryByText("Where are applications picked up?")).not.toBeInTheDocument() + expect(screen.queryByText("Pickup address")).not.toBeInTheDocument() + expect(screen.queryByText("Office hours")).not.toBeInTheDocument() + expect(screen.queryByText("Where are applications dropped off?")).not.toBeInTheDocument() + expect(screen.queryByText("Drop off address")).not.toBeInTheDocument() + expect(screen.queryByText("Received by date")).not.toBeInTheDocument() + expect(screen.queryByText("Received by time")).not.toBeInTheDocument() }) it("should display all the Application Address data", () => { - const { getByText, getAllByText } = render( + render( { ) - expect(getByText("Application address")).toBeInTheDocument() - expect(getByText("Can applications be mailed in?")).toBeInTheDocument() - expect(getByText("Where can applications be mailed in?")).toBeInTheDocument() - expect(getByText("Mailing address")).toBeInTheDocument() - expect(getByText("Can applications be picked up?")).toBeInTheDocument() - expect(getByText("Where are applications picked up?")).toBeInTheDocument() - expect(getByText("Pickup address")).toBeInTheDocument() - expect(getByText("Can applications be dropped off?")).toBeInTheDocument() - expect(getByText("Where are applications dropped off?")).toBeInTheDocument() - expect(getByText("Drop off address")).toBeInTheDocument() - expect(getByText("Are postmarks considered?")).toBeInTheDocument() - expect(getByText("Received by date")).toBeInTheDocument() - expect(getByText("03/14/2025")).toBeInTheDocument() - expect(getByText("Received by time")).toBeInTheDocument() - expect(getByText("08:15 AM")).toBeInTheDocument() - expect(getByText("Additional application submission notes")).toBeInTheDocument() - expect(getByText("Test Submission note")).toBeInTheDocument() - expect(getAllByText("Street address or PO box")).toHaveLength(3) - expect(getAllByText("Apt or unit #")).toHaveLength(3) - expect(getAllByText("City")).toHaveLength(3) - expect(getAllByText("State")).toHaveLength(3) - expect(getAllByText("Zip code")).toHaveLength(3) - expect(getAllByText("Office hours")).toHaveLength(2) - expect(getAllByText("Yes")).toHaveLength(4) - expect(getAllByText("Leasing agent address")).toHaveLength(3) - expect(getByText("1598 Peaceful Lane")).toBeInTheDocument() - expect(getByText("None")).toBeInTheDocument() - expect(getByText("Warrensville Heights")).toBeInTheDocument() - expect(getByText("Ohio")).toBeInTheDocument() - expect(getByText("44128")).toBeInTheDocument() - expect(getByText("2560 Barnes Street")).toBeInTheDocument() - expect(getByText("#13")).toBeInTheDocument() - expect(getByText("Doral")).toBeInTheDocument() - expect(getByText("Florida")).toBeInTheDocument() - expect(getByText("33166")).toBeInTheDocument() - expect(getByText("3897 Benson Street")).toBeInTheDocument() - expect(getByText("#29")).toBeInTheDocument() - expect(getByText("Zurich")).toBeInTheDocument() - expect(getByText("Montana")).toBeInTheDocument() - expect(getByText("59547")).toBeInTheDocument() + expect(screen.getByText("Application address")).toBeInTheDocument() + expect(screen.getByText("Can applications be mailed in?")).toBeInTheDocument() + expect(screen.getByText("Where can applications be mailed in?")).toBeInTheDocument() + expect(screen.getByText("Mailing address")).toBeInTheDocument() + expect(screen.getByText("Can applications be picked up?")).toBeInTheDocument() + expect(screen.getByText("Where are applications picked up?")).toBeInTheDocument() + expect(screen.getByText("Pickup address")).toBeInTheDocument() + expect(screen.getByText("Can applications be dropped off?")).toBeInTheDocument() + expect(screen.getByText("Where are applications dropped off?")).toBeInTheDocument() + expect(screen.getByText("Drop off address")).toBeInTheDocument() + expect(screen.getByText("Are postmarks considered?")).toBeInTheDocument() + expect(screen.getByText("Received by date")).toBeInTheDocument() + expect(screen.getByText("03/14/2025")).toBeInTheDocument() + expect(screen.getByText("Received by time")).toBeInTheDocument() + expect(screen.getByText("08:15 AM")).toBeInTheDocument() + expect(screen.getByText("Additional application submission notes")).toBeInTheDocument() + expect(screen.getByText("Test Submission note")).toBeInTheDocument() + expect(screen.getAllByText("Street address or PO box")).toHaveLength(3) + expect(screen.getAllByText("Apt or unit #")).toHaveLength(3) + expect(screen.getAllByText("City")).toHaveLength(3) + expect(screen.getAllByText("State")).toHaveLength(3) + expect(screen.getAllByText("Zip code")).toHaveLength(3) + expect(screen.getAllByText("Office hours")).toHaveLength(2) + expect(screen.getAllByText("Yes")).toHaveLength(4) + expect(screen.getAllByText("Leasing agent address")).toHaveLength(3) + expect(screen.getByText("1598 Peaceful Lane")).toBeInTheDocument() + expect(screen.getByText("None")).toBeInTheDocument() + expect(screen.getByText("Warrensville Heights")).toBeInTheDocument() + expect(screen.getByText("Ohio")).toBeInTheDocument() + expect(screen.getByText("44128")).toBeInTheDocument() + expect(screen.getByText("2560 Barnes Street")).toBeInTheDocument() + expect(screen.getByText("#13")).toBeInTheDocument() + expect(screen.getByText("Doral")).toBeInTheDocument() + expect(screen.getByText("Florida")).toBeInTheDocument() + expect(screen.getByText("33166")).toBeInTheDocument() + expect(screen.getByText("3897 Benson Street")).toBeInTheDocument() + expect(screen.getByText("#29")).toBeInTheDocument() + expect(screen.getByText("Zurich")).toBeInTheDocument() + expect(screen.getByText("Montana")).toBeInTheDocument() + expect(screen.getByText("59547")).toBeInTheDocument() }) }) describe("should display Application Dates section", () => { it("should display section with mising data", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application dates")).toBeInTheDocument() - expect(getByText("Application due date")).toBeInTheDocument() - expect(getByText("Application due time")).toBeInTheDocument() - expect(getAllByText("None")).toHaveLength(2) - expect(queryByText("Open houses")).not.toBeInTheDocument() - expect(queryByText("Open house")).not.toBeInTheDocument() - expect(queryByText("Date")).not.toBeInTheDocument() - expect(queryByText("Start time")).not.toBeInTheDocument() - expect(queryByText("End time")).not.toBeInTheDocument() - expect(queryByText("URL")).not.toBeInTheDocument() - expect(queryByText("Open house notes")).not.toBeInTheDocument() - expect(queryByText("Done")).not.toBeInTheDocument() + expect(screen.getByText("Application dates")).toBeInTheDocument() + expect(screen.getByText("Application due date")).toBeInTheDocument() + expect(screen.getByText("Application due time")).toBeInTheDocument() + expect(screen.getAllByText("None")).toHaveLength(2) + expect(screen.queryByText("Open houses")).not.toBeInTheDocument() + expect(screen.queryByText("Open house")).not.toBeInTheDocument() + expect(screen.queryByText("Date")).not.toBeInTheDocument() + expect(screen.queryByText("Start time")).not.toBeInTheDocument() + expect(screen.queryByText("End time")).not.toBeInTheDocument() + expect(screen.queryByText("URL")).not.toBeInTheDocument() + expect(screen.queryByText("Open house notes")).not.toBeInTheDocument() + expect(screen.queryByText("Done")).not.toBeInTheDocument() }) it("should display all the Application Dates data", () => { - const { getByText } = render( + render( { ) - expect(getByText("Application dates")).toBeInTheDocument() - expect(getByText("Application due date")).toBeInTheDocument() - expect(getByText("12/20/2024")).toBeInTheDocument() - expect(getByText("Application due time")).toBeInTheDocument() - expect(getByText("03:30 PM")).toBeInTheDocument() - expect(getByText("Open houses")).toBeInTheDocument() - expect(getByText("Date")).toBeInTheDocument() - expect(getByText("02/18/2024")).toBeInTheDocument() - expect(getByText("Start time")).toBeInTheDocument() - expect(getByText("10:30 AM")).toBeInTheDocument() - expect(getByText("End time")).toBeInTheDocument() - expect(getByText("12:15 PM")).toBeInTheDocument() - expect(getByText("Link")).toBeInTheDocument() - - const urlButton = getByText("URL", { selector: "a" }) + expect(screen.getByText("Application dates")).toBeInTheDocument() + expect(screen.getByText("Application due date")).toBeInTheDocument() + expect(screen.getByText("12/20/2024")).toBeInTheDocument() + expect(screen.getByText("Application due time")).toBeInTheDocument() + expect(screen.getByText("03:30 PM")).toBeInTheDocument() + expect(screen.getByText("Open houses")).toBeInTheDocument() + expect(screen.getByText("Date")).toBeInTheDocument() + expect(screen.getByText("02/18/2024")).toBeInTheDocument() + expect(screen.getByText("Start time")).toBeInTheDocument() + expect(screen.getByText("10:30 AM")).toBeInTheDocument() + expect(screen.getByText("End time")).toBeInTheDocument() + expect(screen.getByText("12:15 PM")).toBeInTheDocument() + expect(screen.getByText("Link")).toBeInTheDocument() + + const urlButton = screen.getByText("URL", { selector: "a" }) expect(urlButton).toBeInTheDocument() expect(urlButton).toHaveAttribute("href", "http://test.url.com") - expect(getByText("View")).toBeInTheDocument() + expect(screen.getByText("View")).toBeInTheDocument() }) }) describe("should display Verification section", () => { it("section should be hiden when jurisdiction flag is not set", () => { - const { queryByText } = render( + render( { ) - expect(queryByText("Verification")).not.toBeInTheDocument() - expect(queryByText("I verify that this listing data is valid")).not.toBeInTheDocument() - expect(queryByText("Yes")).not.toBeInTheDocument() + expect(screen.queryByText("Verification")).not.toBeInTheDocument() + expect( + screen.queryByText("I verify that this listing data is valid") + ).not.toBeInTheDocument() + expect(screen.queryByText("Yes")).not.toBeInTheDocument() }) it("should render section when jurisdiction flag is set", () => { - const { getByText } = render( + render( { ) - expect(getByText("Verification")).toBeInTheDocument() - expect(getByText("I verify that this listing data is valid")).toBeInTheDocument() - expect(getByText("Yes")).toBeInTheDocument() + expect(screen.getByText("Verification")).toBeInTheDocument() + expect(screen.getByText("I verify that this listing data is valid")).toBeInTheDocument() + expect(screen.getByText("Yes")).toBeInTheDocument() }) }) }) @@ -1641,7 +1890,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { findByText } = render( + render( { ) - const statusTag = await findByText(status.tagString) + const statusTag = await screen.findByText(status.tagString) expect(statusTag).toBeInTheDocument() } ) @@ -1683,7 +1932,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { getByText } = render( + render( { ) - const editButton = getByText("Edit") + const editButton = screen.getByText("Edit") expect(editButton).toBeInTheDocument() expect(editButton).toHaveAttribute("href", "/listings/Uvbk5qurpB2WI9V6WnNdH/edit") }) @@ -1719,7 +1968,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { getByText } = render( + render( { ) - const copyButton = getByText("Copy", { selector: "button" }) + const copyButton = screen.getByText("Copy", { selector: "button" }) expect(copyButton).toBeInTheDocument() fireEvent.click(copyButton) - const copyDialogHeader = getByText("Copy listing", { selector: "h1" }) + const copyDialogHeader = screen.getByText("Copy listing", { selector: "h1" }) expect(copyDialogHeader).toBeInTheDocument() const copyDialogForm = copyDialogHeader.parentElement.parentElement @@ -1778,7 +2027,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { getByText, queryByText } = render( + render( { ) - const copyButton = getByText("Copy", { selector: "button" }) + const copyButton = screen.getByText("Copy", { selector: "button" }) expect(copyButton).toBeInTheDocument() fireEvent.click(copyButton) - let copyDialogHeader = getByText("Copy listing", { selector: "h1" }) + let copyDialogHeader = screen.getByText("Copy listing", { selector: "h1" }) expect(copyDialogHeader).toBeInTheDocument() const copyDialogForm = copyDialogHeader.parentElement.parentElement @@ -1810,7 +2059,7 @@ describe("listing data", () => { expect(cancelDialogButton).toBeInTheDocument() fireEvent.click(cancelDialogButton) - copyDialogHeader = queryByText("Copy listing", { selector: "h1" }) + copyDialogHeader = screen.queryByText("Copy listing", { selector: "h1" }) expect(copyDialogHeader).not.toBeInTheDocument() }) }) @@ -1829,7 +2078,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { getByText } = render( + render( { ) - const previewButton = getByText("Preview") + const previewButton = screen.getByText("Preview") expect(previewButton).toBeInTheDocument() expect(previewButton).toHaveAttribute("href", "/preview/listings/Uvbk5qurpB2WI9V6WnNdH") }) @@ -1865,7 +2114,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { getByText, queryByText } = render( + render( { ) - const unitSectionHeader = getByText("Listing units", { selector: "h2" }) + const unitSectionHeader = screen.getByText("Listing units", { selector: "h2" }) expect(unitSectionHeader).toBeInTheDocument() const unitSection = unitSectionHeader.parentElement expect(unitSection).toBeInTheDocument() @@ -1907,7 +2156,7 @@ describe("listing data", () => { fireEvent.click(unitViewButton) - let unitDrawerHeader = getByText("Unit", { selector: "h1" }) + let unitDrawerHeader = screen.getByText("Unit", { selector: "h1" }) expect(unitDrawerHeader).toBeInTheDocument() const unitDrawer = unitDrawerHeader.parentElement.parentElement @@ -1930,7 +2179,9 @@ describe("listing data", () => { expect(within(detailsSection).getAllByText("2")).toHaveLength(2) // Eligibility section - const eligibilitySectionHeader = within(unitDrawer).getByText("Eligibility", { selector: "h2" }) + const eligibilitySectionHeader = within(unitDrawer).getByText("Eligibility", { + selector: "h2", + }) expect(eligibilitySectionHeader).toBeInTheDocument() const eligibilitySection = eligibilitySectionHeader.parentElement expect(within(eligibilitySection).getByText("AMI chart")).toBeInTheDocument() @@ -1960,7 +2211,7 @@ describe("listing data", () => { expect(doneButton).toBeInTheDocument() fireEvent.click(doneButton) - unitDrawerHeader = queryByText("Unit", { selector: "h1" }) + unitDrawerHeader = screen.queryByText("Unit", { selector: "h1" }) expect(unitDrawerHeader).not.toBeInTheDocument() }) }) diff --git a/sites/partners/page_content/locale_overrides/general.json b/sites/partners/page_content/locale_overrides/general.json index 4518e03ea3..f7be5b2315 100644 --- a/sites/partners/page_content/locale_overrides/general.json +++ b/sites/partners/page_content/locale_overrides/general.json @@ -134,6 +134,8 @@ "errors.copy.listingNameError": "Create a unique listing name", "errors.maxLessThanMinOccupancyError": "Max occupancy must be greater than or equal to minimum occupancy", "errors.minGreaterThanMaxOccupancyError": "Minimum occupancy must be less than or equal to max occupancy", + "errors.maxLessThanFlatRentValueFrom": "Flat rent to value must be greater than flat rent from value", + "errors.minGreaterThanFlatRentValueTo": "Flat rent from value must be less than flat rent to value", "errors.maxLessThanMinFootageError": "Max square footage must be greater than or equal to minimum square footage", "errors.minGreaterThanMaxFootageError": "Minimum square footage must be less than or equal to max square footage", "errors.maxLessThanMinFloorError": "Max floor must be greater than or equal to minimum floor", @@ -228,6 +230,10 @@ "listings.copyListing": "Copy listing", "listings.createdDate": "Created date", "listings.customOnlineApplicationUrl": "Custom online application URL", + "listings.depositTitle": "Deposit type", + "listings.depositFixed": "Fixed deposit", + "listings.depositRange": "Deposit range", + "listings.depositValue": "Deposit", "listings.depositMax": "Deposit max", "listings.depositMin": "Deposit min", "listings.details.createdDate": "Date created", @@ -239,6 +245,7 @@ "listings.details.updatedDate": "Date updated", "listings.details.you": "You", "listings.developer": "Housing developer", + "listings.propertyManager": "Property Management Account", "listings.dropOffAddress": "Drop off address", "listings.dueDateQuestion": "Is there an application due date?", "listings.editCommunities": "Edit communities", @@ -258,6 +265,10 @@ "listings.leasingAgentAddress": "Leasing agent address", "listings.listingAvailabilityQuestion": "What is the listing availability?", "listings.listingIsAlreadyLive": "This listing is already live. Updates will affect the applicant experience on the housing portal.", + "listings.listingTypeTile": "What kind of listing is this?", + "listings.hasEbllClearanceTitle": "Has this property received HUD EBLL clearance?", + "listings.regulatedListing": "Regulated", + "listings.nonRegulatedListing": "Non-regulated", "listings.listingName": "Listing name", "listings.listingFileNumber": "Listing file number", "listings.listingPhoto": "Listing photo", @@ -370,6 +381,7 @@ "listings.reservedCommunityDescription": "Reserved community description", "listings.reservedCommunityDisclaimer": "Reserved community disclaimer", "listings.reservedCommunityDisclaimerTitle": "Reserved community disclaimer title", + "listings.seniorsOnly": "Is this unit for seniors only?", "listings.includeCommunityDisclaimer": "Do you want to include a community type disclaimer as the first page of the application?", "listings.reviewOrderQuestion": "How is the application review order determined?", "listings.section8Title": "Do you accept Section 8 Housing Choice Vouchers?", @@ -426,7 +438,7 @@ "listings.unit.%incomeRent": "Percentage of income rent", "listings.unit.accessibilityPriorityType": "Accessibility priority type", "listings.unit.add": "Add unit", - "listings.unit.affordableGroupQuantity": "Affordable unit group quantity", + "listings.unit.affordableGroupQuantity": "Unit Group Quantity", "listings.unit.ami": "AMI", "listings.unit.amiChart": "AMI chart", "listings.unit.amiLevel": "AMI level", @@ -488,6 +500,11 @@ "listings.unit.groupVacancies": "Unit group vacancies", "listings.unit.waitlistStatus": "Waitlist status", "listings.unitGroup.add": "Add unit group", + "listings.unitGroup.rentType": "Rent Type", + "listings.unitGroup.fixedRent": "Fixed Rent", + "listings.unitGroup.rentRange": "Rent Range", + "listings.unitGroup.flatRentValueFrom": "Monthly Rent From", + "listings.unitGroup.flatRentValueTo": "Monthly Rent To", "listings.unitGroup.delete": "Delete this unit group", "listings.unitGroup.deleteConf": "Do you really want to delete this unit group?", "listings.unitTypesOrIndividual": "Do you want to show unit types or individual units?", diff --git a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailAdditionalDetails.tsx b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailAdditionalDetails.tsx index 3fce5113af..d0b2ced86b 100644 --- a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailAdditionalDetails.tsx +++ b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailAdditionalDetails.tsx @@ -1,18 +1,65 @@ import React, { useContext } from "react" -import { t } from "@bloom-housing/ui-components" +import { GridCell, t } from "@bloom-housing/ui-components" import { FieldValue, Grid } from "@bloom-housing/ui-seeds" import { ListingContext } from "../../ListingContext" import { getDetailFieldString } from "./helpers" import SectionWithGrid from "../../../shared/SectionWithGrid" +import { AuthContext } from "@bloom-housing/shared-helpers" +import { + EnumListingListingType, + FeatureFlagEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" const DetailAdditionalDetails = () => { const listing = useContext(ListingContext) + const { doJurisdictionsHaveFeatureFlagOn } = useContext(AuthContext) + + const getRequiredDocuments = () => { + let documentsExist = false + const documents = Object.entries(listing?.requiredDocumentsList ?? {}).map( + ([document, value]) => { + if (document && value) { + documentsExist = true + return ( +
  • + {t(`listings.requiredDocuments.${document.trim()}`)} +
  • + ) + } + } + ) + return documentsExist ?
      {documents}
    : <>{t("t.none")} + } + + const enableNonRegulatedListings = doJurisdictionsHaveFeatureFlagOn( + FeatureFlagEnum.enableNonRegulatedListings, + listing.jurisdictions.id + ) + + const showRequiredDocumentsListField = + enableNonRegulatedListings && listing.listingType === EnumListingListingType.nonRegulated return ( + {showRequiredDocumentsListField && ( + + + + {getRequiredDocuments()} + + + + )} - + {getDetailFieldString(listing.requiredDocuments)} diff --git a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailAdditionalFees.tsx b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailAdditionalFees.tsx index 9dfc824613..661a3c32cb 100644 --- a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailAdditionalFees.tsx +++ b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailAdditionalFees.tsx @@ -2,10 +2,14 @@ import React, { useContext } from "react" import { t } from "@bloom-housing/ui-components" import { FieldValue, Grid } from "@bloom-housing/ui-seeds" import { ListingContext } from "../../ListingContext" -import { getDetailFieldString } from "./helpers" +import { getDetailFieldNumber, getDetailFieldString } from "./helpers" import { AuthContext } from "@bloom-housing/shared-helpers" import SectionWithGrid from "../../../shared/SectionWithGrid" -import { FeatureFlagEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { + EnumListingDepositType, + EnumListingListingType, + FeatureFlagEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" const DetailAdditionalFees = () => { const listing = useContext(ListingContext) @@ -39,17 +43,52 @@ const DetailAdditionalFees = () => { {getDetailFieldString(listing.applicationFee)}
    - - - {getDetailFieldString(listing.depositMin)} - - - - - {getDetailFieldString(listing.depositMax)} - - + {listing.listingType === EnumListingListingType.regulated && ( + <> + + + {getDetailFieldString(listing.depositMin)} + + + + + {getDetailFieldString(listing.depositMax)} + + + + )}
    + {listing.listingType === EnumListingListingType.nonRegulated && ( + + + + {listing.depositType === EnumListingDepositType.fixedDeposit + ? t("listings.depositFixed") + : t("listings.depositRange")} + + + {listing.depositType === EnumListingDepositType.fixedDeposit ? ( + + + {getDetailFieldNumber(listing.depositValue)} + + + ) : ( + <> + + + {getDetailFieldString(listing.depositMin)} + + + + + {getDetailFieldString(listing.depositMax)} + + + + )} + + )} diff --git a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailListingIntro.tsx b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailListingIntro.tsx index e0feb88225..43c2cfbe97 100644 --- a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailListingIntro.tsx +++ b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailListingIntro.tsx @@ -4,8 +4,11 @@ import { FieldValue, Grid } from "@bloom-housing/ui-seeds" import { ListingContext } from "../../ListingContext" import { getDetailFieldString } from "./helpers" import SectionWithGrid from "../../../shared/SectionWithGrid" +import { + EnumListingListingType, + FeatureFlagEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" import { AuthContext } from "@bloom-housing/shared-helpers" -import { FeatureFlagEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" const DetailListingIntro = () => { const listing = useContext(ListingContext) @@ -20,6 +23,23 @@ const DetailListingIntro = () => { listing.jurisdictions.id ) + const enableNonRegulatedListings = doJurisdictionsHaveFeatureFlagOn( + FeatureFlagEnum.enableNonRegulatedListings, + listing.jurisdictions.id + ) + + let developerFieldTitle = t("listings.developer") + if (enableHousingDeveloperOwner) { + developerFieldTitle = t("listings.housingDeveloperOwner") + } else if ( + listing.listingType === EnumListingListingType.regulated || + !enableNonRegulatedListings + ) { + developerFieldTitle = t("listings.developer") + } else { + developerFieldTitle = t("listings.propertyManager") + } + return ( {enableListingFileNumber && ( @@ -45,18 +65,29 @@ const DetailListingIntro = () => { - + {getDetailFieldString(listing.developer)} + {enableNonRegulatedListings && ( + + + + {listing.listingType === EnumListingListingType.regulated + ? t("listings.regulatedListing") + : t("listings.nonRegulatedListing")} + + + {listing.listingType === EnumListingListingType.nonRegulated && ( + + + {listing.hasHudEbllClearance ? t("t.yes") : t("t.no")} + + + )} + + )}
    ) } diff --git a/sites/partners/src/components/listings/PaperListingForm/UnitGroupForm.tsx b/sites/partners/src/components/listings/PaperListingForm/UnitGroupForm.tsx index f75a40f691..b029718624 100644 --- a/sites/partners/src/components/listings/PaperListingForm/UnitGroupForm.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/UnitGroupForm.tsx @@ -21,6 +21,7 @@ import { import { AmiChart, EnumUnitGroupAmiLevelMonthlyRentDeterminationType, + RentTypeEnum, UnitAccessibilityPriorityType, YesNoEnum, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" @@ -36,6 +37,7 @@ type UnitGroupFormProps = { draft: boolean nextId: number jurisdiction: string + isNonRegulated?: boolean } const UnitGroupForm = ({ @@ -45,6 +47,7 @@ const UnitGroupForm = ({ draft, nextId, jurisdiction, + isNonRegulated, }: UnitGroupFormProps) => { const [amiChartsOptions, setAmiChartsOptions] = useState([]) const [unitPrioritiesOptions, setUnitPrioritiesOptions] = useState([]) @@ -85,6 +88,9 @@ const UnitGroupForm = ({ const minOccupancy: number = useWatch({ control, name: "minOccupancy" }) const maxOccupancy: number = useWatch({ control, name: "maxOccupancy" }) + const flatRentValueFrom: number = useWatch({ control, name: "flatRentValueFrom" }) + const flatRentValueTo: number = useWatch({ control, name: "flatRentValueTo" }) + // Controls for validating square footage const sqFeetMin: number = useWatch({ control, name: "sqFeetMin" }) const sqFeetMax: number = useWatch({ control, name: "sqFeetMax" }) @@ -98,6 +104,7 @@ const UnitGroupForm = ({ const bathroomMax: number = useWatch({ control, name: "bathroomMax" }) const totalAvailable: number = useWatch({ control, name: "totalAvailable" }) + const rentType = useWatch({ control, name: "rentType" }) const totalCount: number = useWatch({ control, name: "totalCount" }) const numberOccupancyOptions = 8 @@ -370,6 +377,87 @@ const UnitGroupForm = ({ /> + {isNonRegulated && ( + <> + + + + + + {rentType === RentTypeEnum.fixedRent && ( + + + + + + )} + {rentType === RentTypeEnum.rentRange && ( + + + { + void trigger("flatRentValueTo") + void trigger("flatRentValueFrom") + }} + /> + + + { + void trigger("flatRentValueTo") + void trigger("flatRentValueFrom") + }} + /> + + + )} + + )} - - - { + void trigger("floorMin") + void trigger("floorMax") + }, + }} + /> + + + - - - - - -
    - - - - {!!amiLevels.length && ( -
    - -
    - )} - -
    + {!isNonRegulated && ( + + + + )}
    + {!isNonRegulated && ( + <> +
    + + + + {!!amiLevels.length && ( +
    + +
    + )} + +
    +
    +
    + + )} diff --git a/sites/partners/src/components/listings/PaperListingForm/index.tsx b/sites/partners/src/components/listings/PaperListingForm/index.tsx index 3a2ee94640..c3596a3f71 100644 --- a/sites/partners/src/components/listings/PaperListingForm/index.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/index.tsx @@ -215,6 +215,10 @@ const ListingForm = ({ listing, editMode, setListingName, updateListing }: Listi activeFeatureFlags?.find((flag) => flag.name === FeatureFlagEnum.disableListingPreferences) ?.active || false + const enableNonRegulatedListings = + activeFeatureFlags?.find((flag) => flag.name === FeatureFlagEnum.enableNonRegulatedListings) + ?.active || false + useEffect(() => { if (listing?.units) { const tempUnits = listing.units.map((unit, i) => ({ @@ -343,6 +347,10 @@ const ListingForm = ({ listing, editMode, setListingName, updateListing }: Listi formData.listingSection8Acceptance = YesNoEnum.no } + if (!enableNonRegulatedListings) { + formData.listingType = undefined + } + if (successful) { const dataPipeline = new ListingDataPipeline(formData, { preferences: disableListingPreferences ? [] : preferences, @@ -525,7 +533,10 @@ const ListingForm = ({ listing, editMode, setListingName, updateListing }: Listi requiredFields={requiredFields} /> - +