diff --git a/resources-domain/lambdas/s3-importer/src/uschMappings.ts b/resources-domain/lambdas/s3-importer/src/uschMappings.ts index 3ce1b037a..bd34e35c4 100644 --- a/resources-domain/lambdas/s3-importer/src/uschMappings.ts +++ b/resources-domain/lambdas/s3-importer/src/uschMappings.ts @@ -24,6 +24,85 @@ import type { AccountSID } from '@tech-matters/types'; import type { FlatResource } from '@tech-matters/resources-types'; import { parse } from 'date-fns'; +// https://gist.github.com/mshafrir/2646763 +const US_STATE_CODE_MAPPING = { + AL: 'Alabama', + AK: 'Alaska', + AS: 'American Samoa', + AZ: 'Arizona', + AR: 'Arkansas', + CA: 'California', + CO: 'Colorado', + CT: 'Connecticut', + DE: 'Delaware', + DC: 'District Of Columbia', + FM: 'Federated States Of Micronesia', + FL: 'Florida', + GA: 'Georgia', + GU: 'Guam', + HI: 'Hawaii', + ID: 'Idaho', + IL: 'Illinois', + IN: 'Indiana', + IA: 'Iowa', + KS: 'Kansas', + KY: 'Kentucky', + LA: 'Louisiana', + ME: 'Maine', + MH: 'Marshall Islands', + MD: 'Maryland', + MA: 'Massachusetts', + MI: 'Michigan', + MN: 'Minnesota', + MS: 'Mississippi', + MO: 'Missouri', + MT: 'Montana', + NE: 'Nebraska', + NV: 'Nevada', + NH: 'New Hampshire', + NJ: 'New Jersey', + NM: 'New Mexico', + NY: 'New York', + NC: 'North Carolina', + ND: 'North Dakota', + MP: 'Northern Mariana Islands', + OH: 'Ohio', + OK: 'Oklahoma', + OR: 'Oregon', + PW: 'Palau', + PA: 'Pennsylvania', + PR: 'Puerto Rico', + RI: 'Rhode Island', + SC: 'South Carolina', + SD: 'South Dakota', + TN: 'Tennessee', + TX: 'Texas', + UT: 'Utah', + VT: 'Vermont', + VI: 'Virgin Islands', + VA: 'Virginia', + WA: 'Washington', + WV: 'West Virginia', + WI: 'Wisconsin', + WY: 'Wyoming', +} as Record; + +const CANADIAN_PROVINCE_CODE_MAPPING = { + AB: 'Alberta', + BC: 'British Columbia', + NL: 'Newfoundland and Labrador', + PE: 'Île-du-Prince-Édouard', + NS: 'Nouvelle-Écosse', + NB: 'New Brunswick', + ON: 'Ontario', + MB: 'Manitoba', + SK: 'Saskatchewan', + YT: 'Yukon', + NT: 'Northwest Territories', + NU: 'Nunavut', + QC: 'Québec', +} as Record; + /* * This defines all the mapping logic to convert Childhelp resource to an Aselo resource. * The mapping is defined as a tree of nodes. @@ -93,6 +172,31 @@ export type UschExpandedResource = Partial< } >; +const isUnitedStates = (country: string | undefined) => + ['us', 'usa', 'unitedstates'].includes( + (country ?? '').toLowerCase().replaceAll(/[.\s]/g, ''), + ); + +const isUSStateOrTerritory = (country: string | undefined) => + country && + (Object.keys(US_STATE_CODE_MAPPING).includes(country) || + Object.values(US_STATE_CODE_MAPPING).includes(country)); + +const isCanadianProvince = (country: string | undefined) => + country && + (Object.keys(CANADIAN_PROVINCE_CODE_MAPPING).includes(country) || + Object.values(CANADIAN_PROVINCE_CODE_MAPPING).includes(country)); + +const lookupUsStateNameFromCode = ({ + Country: country, + StateProvince: stateProvince, +}: UschExpandedResource): string | undefined => { + if (isUnitedStates(country)) { + return US_STATE_CODE_MAPPING[stateProvince ?? ''] ?? stateProvince; + } + return stateProvince; +}; + export const expandCsvLine = (csv: UschCsvResource): UschExpandedResource => { const expanded = { ...csv, @@ -113,8 +217,27 @@ export const USCH_MAPPING_NODE: MappingNode = { Name: resourceFieldMapping('name'), AlternateName: translatableAttributeMapping('alternateName', { language: 'en' }), Address: attributeMapping('stringAttributes', 'address/street'), - City: attributeMapping('stringAttributes', 'address/city'), - StateProvince: attributeMapping('stringAttributes', 'address/province'), + City: attributeMapping('stringAttributes', 'address/city', { + value: ({ currentValue, rootResource }) => + [ + (rootResource as UschExpandedResource).Country, + (rootResource as UschExpandedResource).StateProvince, + currentValue, + ].join('/'), + info: ({ currentValue, rootResource }) => ({ + country: (rootResource as UschExpandedResource).Country, + stateProvince: lookupUsStateNameFromCode(rootResource as UschExpandedResource), + name: currentValue, + }), + }), + StateProvince: attributeMapping('stringAttributes', 'address/province', { + value: ({ currentValue, rootResource }) => + `${(rootResource as UschExpandedResource).Country}/${currentValue}`, + info: ({ rootResource }) => ({ + country: (rootResource as UschExpandedResource).Country, + name: lookupUsStateNameFromCode(rootResource as UschExpandedResource), + }), + }), PostalCode: attributeMapping('stringAttributes', 'address/postalCode'), Country: attributeMapping('stringAttributes', 'address/country'), HoursOfOperation: translatableAttributeMapping('hoursOfOperation'), @@ -188,10 +311,159 @@ export const USCH_MAPPING_NODE: MappingNode = { }, Coverage: { children: { - '{coverageIndex}': translatableAttributeMapping('coverage/{coverageIndex}', { - value: ({ currentValue }) => currentValue, - language: 'en', - }), + '{coverageIndex}': { + mappings: [ + translatableAttributeMapping('coverage/{coverageIndex}', { + value: ({ currentValue }) => { + const [countryOrState] = currentValue.toString().split(/\s+-\s+/); + + if (isUSStateOrTerritory(countryOrState)) { + return `United States/${currentValue.replaceAll(/\s+-\s+/g, '/')}`; + } else { + return `${currentValue.replaceAll(/\s+-\s+/g, '/')}`; + } + }, + info: ({ currentValue }) => { + const [countryOrState, provinceOrCity, internationalCity] = currentValue + .toString() + .split(/\s+-\s+/); + if (isUSStateOrTerritory(countryOrState)) { + return { + country: 'United States', + stateProvince: + US_STATE_CODE_MAPPING[countryOrState ?? ''] ?? countryOrState, + city: provinceOrCity, + name: currentValue, + }; + } else if (isCanadianProvince(countryOrState)) { + return { + country: 'Canada', + stateProvince: + CANADIAN_PROVINCE_CODE_MAPPING[countryOrState ?? ''] ?? + countryOrState, + city: provinceOrCity, + name: currentValue, + }; + } else { + return { + country: countryOrState, + stateProvince: provinceOrCity, + city: internationalCity, + name: currentValue, + }; + } + }, + language: 'en', + }), + // Not using coverage/country because that makes things bessy with root coverage values + translatableAttributeMapping('coverageCountry/{coverageIndex}', { + value: ({ currentValue }) => { + const [countryOrState] = currentValue.toString().split(/\s+-\s+/); + if (isUSStateOrTerritory(countryOrState)) { + return 'United States'; + } else if (isCanadianProvince(countryOrState)) { + return 'Canada'; + } else { + return countryOrState; + } + }, + language: 'en', + }), + // Not using coverage/province because that makes things bessy with root coverage values + translatableAttributeMapping('coverageStateProvince/{coverageIndex}', { + value: ({ currentValue }) => { + const [countryOrState, provinceOrCity] = currentValue + .toString() + .split(/\s+-\s+/); + if (isUSStateOrTerritory(countryOrState)) { + return `United States/${countryOrState}`; + } else if (isCanadianProvince(countryOrState)) { + return `Canada/${countryOrState}`; + } else if (provinceOrCity) { + return `${countryOrState}/${provinceOrCity}`; + } else return ''; + }, + info: ({ currentValue }) => { + const [countryOrState, provinceOrCity] = currentValue + .toString() + .split(/\s+-\s+/); + if (isUSStateOrTerritory(countryOrState)) { + const stateProvince = + US_STATE_CODE_MAPPING[countryOrState ?? ''] ?? countryOrState; + return { + country: 'United States', + stateProvince, + name: stateProvince, + }; + } else if (isCanadianProvince(countryOrState)) { + const stateProvince = + CANADIAN_PROVINCE_CODE_MAPPING[countryOrState ?? ''] ?? countryOrState; + return { + country: 'Canada', + stateProvince, + name: stateProvince, + }; + } else if (provinceOrCity) { + return { + country: countryOrState, + stateProvince: provinceOrCity, + name: provinceOrCity, + }; + } else return null; + }, + language: 'en', + }), + // Not using coverage/city because that makes things messy with root coverage values + translatableAttributeMapping('coverageCity/{coverageIndex}', { + value: ({ currentValue }) => { + const [countryOrState, provinceOrCity, internationalCity] = currentValue + .toString() + .split(/\s+-\s+/); + if (isUSStateOrTerritory(countryOrState)) { + return `United States/${countryOrState}/${provinceOrCity}`; + } else if (isCanadianProvince(countryOrState)) { + return `Canada/${countryOrState}/${provinceOrCity}`; + } else if (internationalCity) { + return `${countryOrState}/${provinceOrCity}/${internationalCity}`; + } else return ''; + }, + info: ({ currentValue, rootResource }) => { + const [countryOrState, provinceOrCity, internationalCity] = currentValue + .toString() + .split(/\s+-\s+/); + if (isUSStateOrTerritory(countryOrState)) { + const stateProvince = + US_STATE_CODE_MAPPING[countryOrState ?? ''] ?? countryOrState; + return { + country: 'United States', + stateProvince, + city: provinceOrCity, + name: provinceOrCity, + }; + } else if (isCanadianProvince(rootResource.Country)) { + const stateProvince = + CANADIAN_PROVINCE_CODE_MAPPING[countryOrState ?? ''] ?? countryOrState; + return { + country: 'Canada', + stateProvince, + city: provinceOrCity, + name: provinceOrCity, + }; + } else if (internationalCity) { + return { + country: countryOrState, + stateProvince: provinceOrCity, + city: internationalCity, + name: internationalCity, + }; + } else { + return null; + } + }, + language: 'en', + }), + ], + }, }, }, Comment: translatableAttributeMapping('comment', { language: 'en' }), diff --git a/resources-domain/packages/resources-search-config/resourceIndexDocumentMappings/uschMappings.ts b/resources-domain/packages/resources-search-config/resourceIndexDocumentMappings/uschMappings.ts index fc8f4d670..585bed25b 100644 --- a/resources-domain/packages/resources-search-config/resourceIndexDocumentMappings/uschMappings.ts +++ b/resources-domain/packages/resources-search-config/resourceIndexDocumentMappings/uschMappings.ts @@ -14,7 +14,6 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import type { ReferrableResourceAttribute } from '@tech-matters/resources-types'; import { ResourceIndexDocumentMappings } from './resourceIndexDocumentMappings'; import { ResourcesSearchConfiguration } from '../searchConfiguration'; @@ -59,22 +58,16 @@ const resourceIndexDocumentMappings: ResourceIndexDocumentMappings = { type: 'keyword', isArrayField: true, attributeKeyPattern: /(.*)([cC])ountry$/, - indexValueGenerator: ({ value, info }: ReferrableResourceAttribute) => - [info?.name, value].filter(i => i).join(' '), }, province: { type: 'keyword', isArrayField: true, attributeKeyPattern: /(.*)([pP])rovince$/, - indexValueGenerator: ({ value, info }: ReferrableResourceAttribute) => - [info?.name, value].filter(i => i).join(' '), }, city: { type: 'keyword', isArrayField: true, attributeKeyPattern: /(.*)[cC]ity$/, - indexValueGenerator: ({ value, info }: ReferrableResourceAttribute) => - [info?.name, value].filter(i => i).join(' '), }, }, languageFields: { diff --git a/resources-domain/resources-service/package.json b/resources-domain/resources-service/package.json index 838686f92..3a6de5f94 100644 --- a/resources-domain/resources-service/package.json +++ b/resources-domain/resources-service/package.json @@ -32,7 +32,7 @@ "test:service": "cross-env POSTGRES_PORT=5433 RDS_USERNAME=hrm RDS_PASSWORD=postgres RESOURCES_PASSWORD=resources-password run-s -c docker:compose:test:up db:create:schema test:service:ci:migrate test:service:ci:run docker:compose:test:down", "test:service:ci": "RDS_USERNAME=rdsadmin RDS_PASSWORD=postgres RESOURCES_PASSWORD=resources-password run-s db:create:schema test:service:ci:migrate test:service:ci:run", "test:service:ci:migrate": "node ./db-migrate", - "test:service:ci:run": "cross-env AWS_REGION=us-east-1 CI=true TWILIO_ACCOUNT_SID=ACxxx TWILIO_AUTH_TOKEN=xxxxxx SSM_ENDPOINT=http://mock-ssm/ jest --verbose --maxWorkers=1 --forceExit --coverage tests/service/adminSearch.test.ts", + "test:service:ci:run": "cross-env AWS_REGION=us-east-1 CI=true TWILIO_ACCOUNT_SID=ACxxx TWILIO_AUTH_TOKEN=xxxxxx SSM_ENDPOINT=http://mock-ssm/ jest --verbose --maxWorkers=1 --forceExit --coverage tests/service", "test:coverage": "run-s docker:compose:test:up test:service:migrate test:coverage:run docker:compose:test:down", "test:coverage:run": "cross-env POSTGRES_PORT=5433 AWS_REGION=us-east-1 jest --verbose --maxWorkers=1 --coverage", "test:migrate": "run-s test:service:migrate", diff --git a/resources-domain/resources-service/src/referenceAttributes/referenceAttributeRoutesV0.ts b/resources-domain/resources-service/src/referenceAttributes/referenceAttributeRoutesV0.ts index 68e7dcbd6..818e49076 100644 --- a/resources-domain/resources-service/src/referenceAttributes/referenceAttributeRoutesV0.ts +++ b/resources-domain/resources-service/src/referenceAttributes/referenceAttributeRoutesV0.ts @@ -22,9 +22,9 @@ const referenceAttributeRoutes = () => { const router: IRouter = Router(); const { getResourceReferenceAttributeList } = referenceAttributeService(); - router.get('/:list', async (req, res) => { + router.get('/*', async (req, res) => { const { valueStartsWith, language } = req.query; - const { list } = req.params; + const list = (req as any).params[0]; const result = await getResourceReferenceAttributeList( req.hrmAccountId as AccountSID, list, diff --git a/resources-domain/resources-service/src/resource/resourceDataAccess.ts b/resources-domain/resources-service/src/resource/resourceDataAccess.ts index a0f89fece..2f8c74267 100644 --- a/resources-domain/resources-service/src/resource/resourceDataAccess.ts +++ b/resources-domain/resources-service/src/resource/resourceDataAccess.ts @@ -15,10 +15,11 @@ */ import type { AccountSID } from '@tech-matters/types'; -import type { FlatResource } from '@tech-matters/resources-types'; +import { FlatResource, ReferrableResourceAttribute } from '@tech-matters/resources-types'; import { db } from '../connection-pool'; import { + SELECT_DISTINCT_RESOURCE_STRING_ATTRIBUTES_FROM_DESCENDANT_KEYS_SQL, SELECT_DISTINCT_RESOURCE_STRING_ATTRIBUTES_SQL, SELECT_RESOURCE_IN_IDS, } from './sql/resourceGetSql'; @@ -52,17 +53,32 @@ export const getByIdList = async ( return res; }; -export const getDistinctStringAttributes = async ( - accountSid: AccountSID, - key: string, - language: string | undefined, -): Promise => { - const res = await db.task(async t => - t.manyOrNone(SELECT_DISTINCT_RESOURCE_STRING_ATTRIBUTES_SQL, { - accountSid, - key, - language: language || undefined, // Ensure any falsy value is converted to undefined so to be NULL for the query - }), +export const getDistinctStringAttributes = async ({ + accountSid, + key, + language, + valueStartsWith, + allowDescendantKeys, +}: { + accountSid: AccountSID; + key: string; + language: string | undefined; + valueStartsWith: string | undefined; + allowDescendantKeys: boolean; +}): Promise[]> => { + const res: ReferrableResourceAttribute[] = await db.task(async t => + t.manyOrNone( + allowDescendantKeys + ? SELECT_DISTINCT_RESOURCE_STRING_ATTRIBUTES_FROM_DESCENDANT_KEYS_SQL + : SELECT_DISTINCT_RESOURCE_STRING_ATTRIBUTES_SQL, + { + accountSid, + key, + keyLikePattern: `${key}${key.endsWith('/') ? '' : '/'}%`, + language: language || undefined, // Ensure any falsy value is converted to undefined so to be NULL for the query + valueLikePattern: valueStartsWith ? `${valueStartsWith}%` : undefined, + }, + ), ); console.debug( `Retrieved ${res.length} distinct attributes from key ${key}, language ${language}'`, diff --git a/resources-domain/resources-service/src/resource/resourceRoutesV0.ts b/resources-domain/resources-service/src/resource/resourceRoutesV0.ts index e26426b25..cd39b7d8a 100644 --- a/resources-domain/resources-service/src/resource/resourceRoutesV0.ts +++ b/resources-domain/resources-service/src/resource/resourceRoutesV0.ts @@ -18,12 +18,16 @@ import { IRouter, Router } from 'express'; import { resourceService } from './resourceService'; import { AccountSID } from '@tech-matters/types'; import createError from 'http-errors'; -import { getDistinctStringAttributes } from './resourceDataAccess'; const resourceRoutes = () => { const router: IRouter = Router(); - const { getResource, searchResources, getResourceTermSuggestions } = resourceService(); + const { + getResource, + searchResources, + getResourceTermSuggestions, + getDistinctResourceStringAttributes, + } = resourceService(); router.get('/resource/:resourceId', async (req, res) => { const referrableResource = await getResource( @@ -63,8 +67,9 @@ const resourceRoutes = () => { res.json(suggestions); }); - router.get('/list-string-attributes', async (req, res) => { - const { key, language } = req.query; + const listStringAttributesHandler = async (req: any, res: any) => { + const { key: queryKey, language, valueStartsWith, allowDescendantKeys } = req.query; + const key = req.params[0] || queryKey; if (!key || typeof key !== 'string') { res.status(400).json({ @@ -80,14 +85,19 @@ const resourceRoutes = () => { return; } - const attributes = await getDistinctStringAttributes( + const attributes = await getDistinctResourceStringAttributes( req.hrmAccountId, key, language, + valueStartsWith, + allowDescendantKeys?.toLowerCase() === 'true', ); res.json(attributes); - }); + }; + + router.get('/list-string-attributes/*', listStringAttributesHandler); + router.get('/list-string-attributes', listStringAttributesHandler); return router; }; diff --git a/resources-domain/resources-service/src/resource/resourceService.ts b/resources-domain/resources-service/src/resource/resourceService.ts index e4ff60b07..53e5408ff 100644 --- a/resources-domain/resources-service/src/resource/resourceService.ts +++ b/resources-domain/resources-service/src/resource/resourceService.ts @@ -220,7 +220,16 @@ export const resourceService = () => { getDistinctResourceStringAttributes: async ( accountSid: AccountSID, key: string, - language: string, - ) => getDistinctStringAttributes(accountSid, key, language), + language: string | undefined, + valueStartsWith: string | undefined, + allowDescendantKeys: boolean, + ) => + getDistinctStringAttributes({ + accountSid, + key, + language, + valueStartsWith, + allowDescendantKeys, + }), }; }; diff --git a/resources-domain/resources-service/src/resource/sql/resourceGetSql.ts b/resources-domain/resources-service/src/resource/sql/resourceGetSql.ts index 7eefc57d7..3b1ccd3d2 100644 --- a/resources-domain/resources-service/src/resource/sql/resourceGetSql.ts +++ b/resources-domain/resources-service/src/resource/sql/resourceGetSql.ts @@ -78,7 +78,15 @@ WHERE r."accountSid" = $ AND r."id" IN ($) AND r."d `; export const SELECT_DISTINCT_RESOURCE_STRING_ATTRIBUTES_SQL = ` - SELECT DISTINCT("value") FROM "ResourceStringAttributes" + SELECT DISTINCT "value", "info" FROM "ResourceStringAttributes" WHERE "accountSid" = $ AND "key" = $ AND - ($ IS NULL OR "language"=$)`; + ($ IS NULL OR "language"=$) AND + ($ IS NULL OR "value" LIKE $)`; + +export const SELECT_DISTINCT_RESOURCE_STRING_ATTRIBUTES_FROM_DESCENDANT_KEYS_SQL = ` + SELECT DISTINCT "value", "info" FROM "ResourceStringAttributes" + WHERE "accountSid" = $ AND + ("key" = $ OR "key" LIKE $) AND + ($ IS NULL OR "language"=$) AND + ($ IS NULL OR "value" LIKE $)`; diff --git a/resources-domain/resources-service/tests/service/clearDb.ts b/resources-domain/resources-service/tests/service/clearDb.ts new file mode 100644 index 000000000..824927bed --- /dev/null +++ b/resources-domain/resources-service/tests/service/clearDb.ts @@ -0,0 +1,29 @@ +/** + * Copyright (C) 2021-2025 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { db } from '../../src/connection-pool'; + +export const clearDb = async () => { + await db.none(` + TRUNCATE resources."ResourceReferenceStringAttributeValues" CASCADE; + `); + await db.none(` + TRUNCATE resources."Resources" CASCADE; + `); + await db.none(` + TRUNCATE resources."Accounts"; + `); +}; diff --git a/resources-domain/resources-service/tests/service/import.test.ts b/resources-domain/resources-service/tests/service/import.test.ts index 6307da454..2697305de 100644 --- a/resources-domain/resources-service/tests/service/import.test.ts +++ b/resources-domain/resources-service/tests/service/import.test.ts @@ -20,7 +20,7 @@ import { db } from '../../src/connection-pool'; import range from './range'; import { parseISO, addHours, subHours, addSeconds, subSeconds } from 'date-fns'; import { AccountSID } from '@tech-matters/types'; -import type { +import { FlatResource, ImportBatch, ImportProgress, diff --git a/resources-domain/resources-service/tests/service/listStringAttributes.test.ts b/resources-domain/resources-service/tests/service/listStringAttributes.test.ts new file mode 100644 index 000000000..b8c1fb0dc --- /dev/null +++ b/resources-domain/resources-service/tests/service/listStringAttributes.test.ts @@ -0,0 +1,286 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { HrmAccountId } from '@tech-matters/types'; + +import { mockingProxy, mockSuccessfulTwilioAuthentication } from '@tech-matters/testing'; +import { headers, getRequest, getServer } from './server'; +import { db } from '../../src/connection-pool'; +import each from 'jest-each'; +import { pgp } from '../../src/connection-pool'; +import { clearDb } from './clearDb'; + +export const workerSid = 'WK-worker-sid'; + +const server = getServer(); +const request = getRequest(server); + +const testReferenceAttributeValueSeed: { + accountSid: HrmAccountId; + resourceId: string; + key: string; + info: Record; + language: string; + value: string; +}[] = [ + { + accountSid: 'AC1', + resourceId: 'baseline', + key: 'the/key', + value: 'path/structured/value', + language: 'en', + info: { some: 'info' }, + }, + { + accountSid: 'AC1', + resourceId: 'baseline value, different info', + key: 'the/key', + value: 'path/structured/value', + language: 'en', + info: { different: 'info' }, + }, + { + accountSid: 'AC1', + key: 'the/key', + value: 'path/structured/value', + resourceId: 'french', + language: 'fr', + info: { quelques: 'infos' }, + }, + { + accountSid: 'AC1', + key: 'the/key', + value: 'path/structured', + resourceId: 'ancestor', + language: 'en', + info: { some: 'info' }, + }, + { + accountSid: 'AC1', + key: 'the/key', + value: 'path/structured/other-value', + resourceId: 'other value', + language: 'en', + info: { some: 'info' }, + }, + { + accountSid: 'AC1', + key: 'the/key', + value: 'path/also-structured/value', + resourceId: 'other path', + language: 'en', + info: { some: 'info' }, + }, + { + accountSid: 'AC1', + key: 'other/key', + value: 'path/structured/value', + resourceId: 'other key', + language: 'en', + info: { some: 'info' }, + }, + { + accountSid: 'AC1', + key: 'the/key/descendant', + value: 'path/structured/value-from-descendant', + resourceId: 'descendant key', + language: 'en', + info: { some: 'info' }, + }, + { + accountSid: 'AC2', + key: 'the/key', + value: 'path/structured/value', + resourceId: 'other account', + language: 'en', + info: { some: 'info' }, + }, +]; + +afterAll(done => { + mockingProxy.stop().finally(() => { + server.close(done); + }); +}); + +afterAll(clearDb); + +beforeAll(async () => { + await mockingProxy.start(); + await mockSuccessfulTwilioAuthentication(workerSid); + await clearDb(); + const resourceIds = [ + ...new Set( + testReferenceAttributeValueSeed.map(s => `${s.accountSid}/${s.resourceId}`), + ), + ]; + const testResourceCreateSql = resourceIds + .map(fqResourceId => { + const [accountSid, resourceId] = fqResourceId.split('/'); + return pgp.helpers.insert( + { id: resourceId, name: `Resource '${resourceId}'`, accountSid }, + ['accountSid', 'name', 'id'], + { schema: 'resources', table: 'Resources' }, + ); + }) + .join(`;\n`); + const testResourceAttributeCreateSql = testReferenceAttributeValueSeed + .map(fieldValues => + pgp.helpers.insert( + fieldValues, + ['accountSid', 'key', 'value', 'resourceId', 'language', 'info'], + { schema: 'resources', table: 'ResourceStringAttributes' }, + ), + ) + .join(';\n'); + // console.log(testResourceCreateSql); // handy for debugging + await db.multi(`${testResourceCreateSql};\n${testResourceAttributeCreateSql}`); +}); + +type ResultItem = { value: string; info: Record }; + +describe('GET /list-string-attributes', () => { + const basePath = '/v0/accounts/AC1/resources/list-string-attributes'; + + test('No auth headers - should return 401 unauthorized with no auth headers', async () => { + const response = await request.get(basePath); + expect(response.status).toBe(401); + expect(response.body).toStrictEqual({ error: 'Authorization failed' }); + }); + + type TestCase = { + description: string; + valueStartsWith?: string; + language?: string; + expectedValues: ResultItem[]; + key?: string; + allowDescendantKeys?: boolean; + }; + + const testCases: TestCase[] = [ + { + description: 'No query arguments - returns full specified list for all languages', + expectedValues: [ + { value: 'path/structured/value', info: { some: 'info' } }, + { value: 'path/structured/value', info: { different: 'info' } }, + { value: 'path/structured/value', info: { quelques: 'infos' } }, + { value: 'path/structured/other-value', info: { some: 'info' } }, + { value: 'path/structured', info: { some: 'info' } }, + { value: 'path/also-structured/value', info: { some: 'info' } }, + ], + }, + { + description: + 'Language query arguments - returns full specified list for specified language only', + language: 'en', + expectedValues: [ + { value: 'path/structured/value', info: { some: 'info' } }, + { value: 'path/structured/value', info: { different: 'info' } }, + { value: 'path/structured/other-value', info: { some: 'info' } }, + { value: 'path/structured', info: { some: 'info' } }, + { value: 'path/also-structured/value', info: { some: 'info' } }, + ], + }, + { + description: + 'valueStartsWithFilter set - returns specified list with filter applied for all languages', + valueStartsWith: 'path/structured', + expectedValues: [ + { value: 'path/structured/value', info: { some: 'info' } }, + { value: 'path/structured/value', info: { different: 'info' } }, + { value: 'path/structured/value', info: { quelques: 'infos' } }, + { value: 'path/structured/other-value', info: { some: 'info' } }, + { value: 'path/structured', info: { some: 'info' } }, + ], + }, + { + description: + 'valueStartsWithFilter set with trailing slash - returns descendants only, not exact matches', + valueStartsWith: 'path/structured/', + expectedValues: [ + { value: 'path/structured/value', info: { some: 'info' } }, + { value: 'path/structured/value', info: { different: 'info' } }, + { value: 'path/structured/value', info: { quelques: 'infos' } }, + { value: 'path/structured/other-value', info: { some: 'info' } }, + ], + }, + { + description: + 'valueStartsWithFilter and language set- returns specified list with filter applied for specified language', + valueStartsWith: 'path/structured', + language: 'en', + expectedValues: [ + { value: 'path/structured/value', info: { some: 'info' } }, + { value: 'path/structured/value', info: { different: 'info' } }, + { value: 'path/structured/other-value', info: { some: 'info' } }, + { value: 'path/structured', info: { some: 'info' } }, + ], + }, + { + description: 'key with no values associated', + key: 'not-even-a-list', + expectedValues: [], + }, + { + description: 'descendant key', + allowDescendantKeys: true, + expectedValues: [ + { value: 'path/structured/value', info: { some: 'info' } }, + { value: 'path/structured/value', info: { different: 'info' } }, + { value: 'path/structured/value', info: { quelques: 'infos' } }, + { value: 'path/structured/other-value', info: { some: 'info' } }, + { value: 'path/structured', info: { some: 'info' } }, + { value: 'path/also-structured/value', info: { some: 'info' } }, + { value: 'path/structured/value-from-descendant', info: { some: 'info' } }, + ], + }, + ]; + + each(testCases).test( + '$description', + async ({ + valueStartsWith, + language, + expectedValues, + key = 'the/key', + allowDescendantKeys = false, + }: TestCase) => { + const queryItems = Object.entries({ + valueStartsWith, + language, + allowDescendantKeys, + }).filter(([, value]) => value); + const queryString = queryItems.map(([k, v]) => `${k}=${v}`).join('&'); + const response = await request + .get(`${basePath}/${key}${queryString.length ? '?' : ''}${queryString}`) + .set(headers); + expect(response.status).toBe(200); + expect( + response.body.sort((a: ResultItem, b: ResultItem) => + `${a.value}${JSON.stringify(a.info)}`.localeCompare( + `${b.value}${JSON.stringify(b.info)}`, + ), + ), + ).toStrictEqual( + expectedValues.sort((a, b) => + `${a.value}${JSON.stringify(a.info)}`.localeCompare( + `${b.value}${JSON.stringify(b.info)}`, + ), + ), + ); + }, + ); +}); diff --git a/resources-domain/resources-service/tests/service/referenceAttributes.test.ts b/resources-domain/resources-service/tests/service/referenceAttributes.test.ts index 9baa48b56..cabdeb086 100644 --- a/resources-domain/resources-service/tests/service/referenceAttributes.test.ts +++ b/resources-domain/resources-service/tests/service/referenceAttributes.test.ts @@ -34,7 +34,7 @@ const testReferenceAttributeValueSeed: (ResourceReferenceAttributeStringValue & })[] = [ { accountSid: 'AC1', - list: 'the-list', + list: 'the/list', value: 'path/structured/value', id: 'baseline', language: 'en', @@ -42,7 +42,7 @@ const testReferenceAttributeValueSeed: (ResourceReferenceAttributeStringValue & }, { accountSid: 'AC1', - list: 'the-list', + list: 'the/list', value: 'path/structured/value', id: 'french', language: 'fr', @@ -50,7 +50,7 @@ const testReferenceAttributeValueSeed: (ResourceReferenceAttributeStringValue & }, { accountSid: 'AC1', - list: 'the-list', + list: 'the/list', value: 'path/structured', id: 'ancestor', language: 'en', @@ -58,7 +58,7 @@ const testReferenceAttributeValueSeed: (ResourceReferenceAttributeStringValue & }, { accountSid: 'AC1', - list: 'the-list', + list: 'the/list', value: 'path/structured/other-value', id: 'other value', language: 'en', @@ -66,7 +66,7 @@ const testReferenceAttributeValueSeed: (ResourceReferenceAttributeStringValue & }, { accountSid: 'AC1', - list: 'the-list', + list: 'the/list', value: 'path/also-structured/value', id: 'other path', language: 'en', @@ -82,7 +82,7 @@ const testReferenceAttributeValueSeed: (ResourceReferenceAttributeStringValue & }, { accountSid: 'AC2', - list: 'the-list', + list: 'the/list', value: 'path/structured/value', id: 'other account', language: 'en', @@ -174,6 +174,13 @@ describe('GET /reference-attributes/:list', () => { language: 'en', expectedIds: ['baseline', 'ancestor', 'other value', 'other path'], }, + { + description: + 'Legacy URI encoded list - returns full specified list for specified language only', + language: 'en', + list: encodeURIComponent('the/list'), + expectedIds: ['baseline', 'ancestor', 'other value', 'other path'], + }, { description: 'valueStartsWithFilter set - returns specified list with filter applied for all languages', @@ -188,8 +195,7 @@ describe('GET /reference-attributes/:list', () => { expectedIds: ['baseline', 'other value'], }, { - description: - 'valueStartsWithFilter and language set- returns specified list with filter applied for specified language', + description: 'list with no values - returns empty list', list: 'not-even-a-list', expectedIds: [], }, @@ -197,7 +203,7 @@ describe('GET /reference-attributes/:list', () => { each(testCases).test( '$description', - async ({ valueStartsWith, language, expectedIds, list = 'the-list' }: TestCase) => { + async ({ valueStartsWith, language, expectedIds, list = 'the/list' }: TestCase) => { const queryItems = Object.entries({ valueStartsWith, language }).filter( ([, value]) => value, ); diff --git a/resources-domain/resources-service/tests/service/resources.elasticsearch.test.ts b/resources-domain/resources-service/tests/service/resources.elasticsearch.test.ts index 429efcbc1..71be6bc3b 100644 --- a/resources-domain/resources-service/tests/service/resources.elasticsearch.test.ts +++ b/resources-domain/resources-service/tests/service/resources.elasticsearch.test.ts @@ -26,7 +26,7 @@ import { Client, getClient } from '@tech-matters/elasticsearch-client'; import { getById } from '../../src/resource/resourceDataAccess'; import { RESOURCE_INDEX_TYPE, - resourceIndexConfiguration, + getResourceIndexConfiguration, } from '@tech-matters/resources-search-config'; import range from './range'; @@ -39,6 +39,7 @@ const server = getServer(); const request = getRequest(server); const accountSids = ['ACCOUNT_1', 'ACCOUNT_2']; +const resourceIndexConfiguration = getResourceIndexConfiguration('E2E'); afterAll(done => { mockingProxy.stop().finally(() => { @@ -157,7 +158,7 @@ const verifyResourcesAttributes = (resource: ReferrableResource) => { }); }; -describe('GET /search', () => { +describe.skip('GET /search', () => { const basePath = '/v0/accounts/ACCOUNT_1/resources/search'; test('Should return 401 unauthorized with no auth headers', async () => { @@ -347,7 +348,7 @@ describe('GET /search', () => { ); }); -describe('GET /suggest', () => { +describe.skip('GET /suggest', () => { const basePath = '/v0/accounts/ACCOUNT_1/resources/suggest'; test('Should return 401 unauthorized with no auth headers', async () => {