diff --git a/.github/workflows/ci-frontend-e2e.yml b/.github/workflows/ci-frontend-e2e.yml index 43bbabc6a..2a98d835d 100644 --- a/.github/workflows/ci-frontend-e2e.yml +++ b/.github/workflows/ci-frontend-e2e.yml @@ -43,7 +43,7 @@ jobs: - name: Start API Server for e2e tests run: | cd ../api - make init db-seed-local start & + make init db-seed-local populate-search-opportunities start & cd ../frontend # Ensure the API wait script is executable chmod +x ../api/bin/wait-for-api.sh diff --git a/frontend/package.json b/frontend/package.json index 9c41ec3c6..b3106ec78 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "scripts": { "build": "next build", "dev": "next dev", + "debug": "NODE_OPTIONS='--inspect' next dev", "format": "prettier --write '**/*.{js,json,md,mdx,ts,tsx,scss,yaml,yml}'", "format-check": "prettier --check '**/*.{js,json,md,mdx,ts,tsx,scss,yaml,yml}'", "lint": "next lint --dir src --dir stories --dir .storybook --dir tests --dir scripts --dir frontend --dir lib --dir types", diff --git a/frontend/src/app/api/BaseApi.ts b/frontend/src/app/api/BaseApi.ts index 3ab619432..b5203d03f 100644 --- a/frontend/src/app/api/BaseApi.ts +++ b/frontend/src/app/api/BaseApi.ts @@ -15,8 +15,6 @@ import { ValidationError, } from "src/errors"; import { QueryParamData } from "src/services/search/searchfetcher/SearchFetcher"; -// TODO (#1682): replace search specific references (since this is a generic API file that any -// future page or different namespace could use) import { APIResponse } from "src/types/apiResponseTypes"; export type ApiMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT"; @@ -24,23 +22,19 @@ export interface JSONRequestBody { [key: string]: unknown; } -interface APIResponseError { - field: string; - message: string; - type: string; -} - export interface HeadersDict { [header: string]: string; } export default abstract class BaseApi { - // Root path of API resource without leading slash. - abstract get basePath(): string; + // Root path of API resource without leading slash, can be overridden by implementing API classes as necessary + get basePath() { + return environment.API_URL; + } - // API version + // API version, can be overridden by implementing API classes as necessary get version() { - return "v0.1"; + return "v1"; } // Namespace representing the API resource @@ -54,29 +48,28 @@ export default abstract class BaseApi { if (environment.API_AUTH_TOKEN) { headers["X-AUTH"] = environment.API_AUTH_TOKEN; } + headers["Content-Type"] = "application/json"; return headers; } /** * Send an API request. */ - async request( + async request( method: ApiMethod, - basePath: string, - namespace: string, subPath: string, queryParamData?: QueryParamData, body?: JSONRequestBody, options: { additionalHeaders?: HeadersDict; } = {}, - ) { + ): Promise { const { additionalHeaders = {} } = options; const url = createRequestUrl( method, - basePath, + this.basePath, this.version, - namespace, + this.namespace, subPath, body, ); @@ -85,8 +78,7 @@ export default abstract class BaseApi { ...this.headers, }; - headers["Content-Type"] = "application/json"; - const response = await this.sendRequest( + const response = await this.sendRequest( url, { body: method === "GET" || !body ? null : createRequestBody(body), @@ -102,16 +94,16 @@ export default abstract class BaseApi { /** * Send a request and handle the response */ - private async sendRequest( + private async sendRequest( url: string, fetchOptions: RequestInit, queryParamData?: QueryParamData, - ) { - let response: Response; - let responseBody: APIResponse; + ): Promise { + let response; + let responseBody; try { response = await fetch(url, fetchOptions); - responseBody = (await response.json()) as APIResponse; + responseBody = (await response.json()) as ResponseType; } catch (error) { // API most likely down, but also possibly an error setting up or sending a request // or parsing the response. @@ -121,16 +113,7 @@ export default abstract class BaseApi { handleNotOkResponse(responseBody, url, queryParamData); } - const { data, message, pagination_info, status_code, warnings } = - responseBody; - - return { - data, - message, - pagination_info, - status_code, - warnings, - }; + return responseBody; } } @@ -149,14 +132,14 @@ export function createRequestUrl( let url = [...cleanedPaths].join("/"); if (method === "GET" && body && !(body instanceof FormData)) { // Append query string to URL - const body: { [key: string]: string } = {}; + const newBody: { [key: string]: string } = {}; Object.entries(body).forEach(([key, value]) => { const stringValue = typeof value === "string" ? value : JSON.stringify(value); - body[key] = stringValue; + newBody[key] = stringValue; }); - const params = new URLSearchParams(body).toString(); + const params = new URLSearchParams(newBody).toString(); url = `${url}?${params}`; } return url; @@ -206,7 +189,7 @@ function handleNotOkResponse( throwError(response, url, searchInputs); } else { if (errors) { - const firstError = errors[0] as APIResponseError; + const firstError = errors[0]; throwError(response, url, searchInputs, firstError); } } @@ -216,9 +199,9 @@ const throwError = ( response: APIResponse, url: string, searchInputs?: QueryParamData, - firstError?: APIResponseError, + firstError?: unknown, ) => { - const { status_code, message } = response; + const { status_code = 0, message = "" } = response; console.error( `API request error at ${url} (${status_code}): ${message}`, searchInputs, diff --git a/frontend/src/app/api/OpportunityListingAPI.ts b/frontend/src/app/api/OpportunityListingAPI.ts index ec6ddc83e..dfa708f4f 100644 --- a/frontend/src/app/api/OpportunityListingAPI.ts +++ b/frontend/src/app/api/OpportunityListingAPI.ts @@ -1,19 +1,10 @@ import "server-only"; -import { environment } from "src/constants/environments"; import { OpportunityApiResponse } from "src/types/opportunity/opportunityResponseTypes"; import BaseApi from "./BaseApi"; export default class OpportunityListingAPI extends BaseApi { - get version(): string { - return "v1"; - } - - get basePath(): string { - return environment.API_URL; - } - get namespace(): string { return "opportunities"; } @@ -21,13 +12,10 @@ export default class OpportunityListingAPI extends BaseApi { async getOpportunityById( opportunityId: number, ): Promise { - const subPath = `${opportunityId}`; - const response = (await this.request( + const response = await this.request( "GET", - this.basePath, - this.namespace, - subPath, - )) as OpportunityApiResponse; + `${opportunityId}`, + ); return response; } } diff --git a/frontend/src/app/api/SearchOpportunityAPI.ts b/frontend/src/app/api/SearchOpportunityAPI.ts index a75dfb3ce..37773c258 100644 --- a/frontend/src/app/api/SearchOpportunityAPI.ts +++ b/frontend/src/app/api/SearchOpportunityAPI.ts @@ -1,37 +1,48 @@ import "server-only"; -import { environment } from "src/constants/environments"; +import BaseApi from "src/app/api/BaseApi"; import { QueryParamData } from "src/services/search/searchfetcher/SearchFetcher"; import { PaginationOrderBy, PaginationRequestBody, - PaginationSortDirection, SearchFetcherActionType, SearchFilterRequestBody, SearchRequestBody, } from "src/types/search/searchRequestTypes"; - -import BaseApi from "./BaseApi"; +import { SearchAPIResponse } from "src/types/search/searchResponseTypes"; + +const orderByFieldLookup = { + opportunityNumber: "opportunity_number", + opportunityTitle: "opportunity_title", + agency: "agency_code", + postedDate: "post_date", + closeDate: "close_date", +}; + +type FrontendFilterNames = + | "status" + | "fundingInstrument" + | "eligibility" + | "agency" + | "category"; + +const filterNameMap = { + status: "opportunity_status", + fundingInstrument: "funding_instrument", + eligibility: "applicant_type", + agency: "agency", + category: "funding_category", +} as const; export default class SearchOpportunityAPI extends BaseApi { - get basePath(): string { - return environment.API_URL; - } - get namespace(): string { return "opportunities"; } - get headers() { - const baseHeaders = super.headers; - const searchHeaders = {}; - return { ...baseHeaders, ...searchHeaders }; - } - async searchOpportunities(searchInputs: QueryParamData) { const { query } = searchInputs; - const filters = this.buildFilters(searchInputs); - const pagination = this.buildPagination(searchInputs); + const filters = buildFilters(searchInputs); + const pagination = buildPagination(searchInputs); const requestBody: SearchRequestBody = { pagination }; @@ -44,93 +55,68 @@ export default class SearchOpportunityAPI extends BaseApi { requestBody.query = query; } - const subPath = "search"; - const response = await this.request( + const response = await this.request( "POST", - this.basePath, - this.namespace, - subPath, + "search", searchInputs, requestBody, ); return response; } +} - // Build with one_of syntax - private buildFilters(searchInputs: QueryParamData): SearchFilterRequestBody { - const { status, fundingInstrument, eligibility, agency, category } = - searchInputs; - const filters: SearchFilterRequestBody = {}; - - if (status && status.size > 0) { - filters.opportunity_status = { one_of: Array.from(status) }; - } - if (fundingInstrument && fundingInstrument.size > 0) { - filters.funding_instrument = { one_of: Array.from(fundingInstrument) }; - } - - if (eligibility && eligibility.size > 0) { - // Note that eligibility gets remapped to the API name of "applicant_type" - filters.applicant_type = { one_of: Array.from(eligibility) }; - } - - if (agency && agency.size > 0) { - filters.agency = { one_of: Array.from(agency) }; - } - - if (category && category.size > 0) { - // Note that category gets remapped to the API name of "funding_category" - filters.funding_category = { one_of: Array.from(category) }; - } - - return filters; - } - - private buildPagination(searchInputs: QueryParamData): PaginationRequestBody { - const { sortby, page, fieldChanged } = searchInputs; - - // When performing an update (query, filter, sortby change) - we want to - // start back at the 1st page (we never want to retain the current page). - // In addition to this statement - on the client (handleSubmit in useSearchFormState), we - // clear the page query param and set the page back to 1. - // On initial load (SearchFetcherActionType.InitialLoad) we honor the page the user sent. There is validation guards - // in convertSearchParamstoProperTypes keep 1<= page <= max_possible_page - const page_offset = - searchInputs.actionType === SearchFetcherActionType.Update && - fieldChanged !== "pagination" - ? 1 - : page; - - const orderByFieldLookup = { - opportunityNumber: "opportunity_number", - opportunityTitle: "opportunity_title", - agency: "agency_code", - postedDate: "post_date", - closeDate: "close_date", - }; - - let order_by: PaginationOrderBy = "post_date"; - if (sortby) { - for (const [key, value] of Object.entries(orderByFieldLookup)) { - if (sortby.startsWith(key)) { - order_by = value as PaginationOrderBy; - break; // Stop searching after the first match is found - } +// Translate frontend filter param names to expected backend parameter names, and use one_of syntax +export const buildFilters = ( + searchInputs: QueryParamData, +): SearchFilterRequestBody => { + return Object.entries(filterNameMap).reduce( + (filters, [frontendFilterName, backendFilterName]) => { + const filterData = + searchInputs[frontendFilterName as FrontendFilterNames]; + if (filterData && filterData.size) { + filters[backendFilterName] = { one_of: Array.from(filterData) }; + } + return filters; + }, + {} as SearchFilterRequestBody, + ); +}; + +export const buildPagination = ( + searchInputs: QueryParamData, +): PaginationRequestBody => { + const { sortby, page, fieldChanged } = searchInputs; + + // When performing an update (query, filter, sortby change) - we want to + // start back at the 1st page (we never want to retain the current page). + // In addition to this statement - on the client (handleSubmit in useSearchFormState), we + // clear the page query param and set the page back to 1. + // On initial load (SearchFetcherActionType.InitialLoad) we honor the page the user sent. There is validation guards + // in convertSearchParamstoProperTypes keep 1<= page <= max_possible_page + const page_offset = + searchInputs.actionType === SearchFetcherActionType.Update && + fieldChanged !== "pagination" + ? 1 + : page; + + let order_by: PaginationOrderBy = "post_date"; + if (sortby) { + for (const [key, value] of Object.entries(orderByFieldLookup)) { + if (sortby.startsWith(key)) { + order_by = value as PaginationOrderBy; + break; // Stop searching after the first match is found } } + } - // default to descending - let sort_direction: PaginationSortDirection = "descending"; - if (sortby) { - sort_direction = sortby?.endsWith("Desc") ? "descending" : "ascending"; - } + const sort_direction = + sortby && !sortby.endsWith("Desc") ? "ascending" : "descending"; - return { - order_by, - page_offset, - page_size: 25, - sort_direction, - }; - } -} + return { + order_by, + page_offset, + page_size: 25, + sort_direction, + }; +}; diff --git a/frontend/src/services/search/searchfetcher/APISearchFetcher.ts b/frontend/src/services/search/searchfetcher/APISearchFetcher.ts index 92c53db9b..ce3ad0fec 100644 --- a/frontend/src/services/search/searchfetcher/APISearchFetcher.ts +++ b/frontend/src/services/search/searchfetcher/APISearchFetcher.ts @@ -21,9 +21,7 @@ export class APISearchFetcher extends SearchFetcher { // await new Promise((resolve) => setTimeout(resolve, 13250)); const response: SearchAPIResponse = - (await this.searchApi.searchOpportunities( - searchInputs, - )) as SearchAPIResponse; + await this.searchApi.searchOpportunities(searchInputs); response.actionType = searchInputs.actionType; response.fieldChanged = searchInputs.fieldChanged; diff --git a/frontend/src/types/opportunity/opportunityResponseTypes.ts b/frontend/src/types/opportunity/opportunityResponseTypes.ts index d46cb954e..e3a54f37b 100644 --- a/frontend/src/types/opportunity/opportunityResponseTypes.ts +++ b/frontend/src/types/opportunity/opportunityResponseTypes.ts @@ -1,3 +1,5 @@ +import { APIResponse } from "src/types/apiResponseTypes"; + export interface OpportunityAssistanceListing { assistance_listing_number: string; program_title: string; @@ -52,8 +54,6 @@ export interface Opportunity { updated_at: string; } -export interface OpportunityApiResponse { +export interface OpportunityApiResponse extends APIResponse { data: Opportunity; - message: string; - status_code: number; } diff --git a/frontend/src/types/search/searchResponseTypes.ts b/frontend/src/types/search/searchResponseTypes.ts index 9d2a84c59..ce88a5987 100644 --- a/frontend/src/types/search/searchResponseTypes.ts +++ b/frontend/src/types/search/searchResponseTypes.ts @@ -1,4 +1,4 @@ -import { PaginationInfo } from "src/types/apiResponseTypes"; +import { APIResponse, PaginationInfo } from "src/types/apiResponseTypes"; import { SearchFetcherActionType } from "./searchRequestTypes"; @@ -55,13 +55,9 @@ export interface Opportunity { updated_at: string; } -export interface SearchAPIResponse { +export interface SearchAPIResponse extends APIResponse { data: Opportunity[]; - message: string; pagination_info: PaginationInfo; - status_code: number; - warnings?: unknown[] | null | undefined; - errors?: unknown[] | null | undefined; actionType?: SearchFetcherActionType; fieldChanged?: string; } diff --git a/frontend/tests/api/BaseApi.test.ts b/frontend/tests/api/BaseApi.test.ts index 05c71f91b..833ddf0fd 100644 --- a/frontend/tests/api/BaseApi.test.ts +++ b/frontend/tests/api/BaseApi.test.ts @@ -1,6 +1,10 @@ import "server-only"; -import BaseApi, { ApiMethod, JSONRequestBody } from "src/app/api/BaseApi"; +import BaseApi, { + ApiMethod, + createRequestUrl, + JSONRequestBody, +} from "src/app/api/BaseApi"; import { NetworkError, UnauthorizedError } from "src/errors"; // Define a concrete implementation of BaseApi for testing @@ -40,18 +44,16 @@ describe("BaseApi", () => { it("sends a GET request to the API", async () => { const method: ApiMethod = "GET"; - const basePath = "http://mydomain:8080"; - const namespace = "mynamespace"; const subPath = "myendpointendpoint"; - await testApi.request(method, basePath, namespace, subPath, searchInputs); + await testApi.request(method, subPath, searchInputs); const expectedHeaders = { "Content-Type": "application/json", }; expect(fetch).toHaveBeenCalledWith( - expect.any(String), + "api/v1/test/myendpointendpoint", expect.objectContaining({ method, headers: expectedHeaders, @@ -61,8 +63,6 @@ describe("BaseApi", () => { it("sends a POST request to the API", async () => { const method: ApiMethod = "POST"; - const basePath = "http://mydomain:8080"; - const namespace = "mynamespace"; const subPath = "myendpointendpoint"; const body: JSONRequestBody = { pagination: { @@ -73,21 +73,14 @@ describe("BaseApi", () => { }, }; - await testApi.request( - method, - basePath, - namespace, - subPath, - searchInputs, - body, - ); + await testApi.request(method, subPath, searchInputs, body); const expectedHeaders = { "Content-Type": "application/json", }; expect(fetch).toHaveBeenCalledWith( - expect.any(String), + "api/v1/test/myendpointendpoint", expect.objectContaining({ method, headers: expectedHeaders, @@ -117,12 +110,10 @@ describe("BaseApi", () => { it("throws an UnauthorizedError for a 401 response", async () => { const method = "GET"; - const basePath = "http://mydomain:8080"; - const namespace = "mynamespace"; const subPath = "endpoint"; await expect( - testApi.request(method, basePath, namespace, subPath, searchInputs), + testApi.request(method, subPath, searchInputs), ).rejects.toThrow(UnauthorizedError); }); }); @@ -139,13 +130,56 @@ describe("BaseApi", () => { it("throws a NetworkError when fetch fails", async () => { const method = "GET"; - const basePath = "http://mydomain:8080"; - const namespace = "mynamespace"; const subPath = "endpoint"; await expect( - testApi.request(method, basePath, namespace, subPath, searchInputs), + testApi.request(method, subPath, searchInputs), ).rejects.toThrow(NetworkError); }); }); }); + +describe("createRequestUrl", () => { + it("creates the correct url without search params", () => { + const method = "GET"; + let basePath = ""; + let version = ""; + let namespace = ""; + let subpath = ""; + + expect( + createRequestUrl(method, basePath, version, namespace, subpath), + ).toEqual(""); + + basePath = "basePath"; + version = "version"; + namespace = "namespace"; + subpath = "subpath"; + + expect( + createRequestUrl(method, basePath, version, namespace, subpath), + ).toEqual("basePath/version/namespace/subpath"); + + // note that leading slashes are removed but trailing slashes are not + basePath = "/basePath"; + version = "/version"; + namespace = "/namespace"; + subpath = "/subpath/"; + + expect( + createRequestUrl(method, basePath, version, namespace, subpath), + ).toEqual("basePath/version/namespace/subpath/"); + }); + + it("creates the correct url with search params", () => { + const method = "GET"; + const body = { + simpleParam: "simpleValue", + complexParam: { nestedParam: ["complex", "values", 1] }, + }; + + expect(createRequestUrl(method, "", "", "", "", body)).toEqual( + "?simpleParam=simpleValue&complexParam=%7B%22nestedParam%22%3A%5B%22complex%22%2C%22values%22%2C1%5D%7D", + ); + }); +}); diff --git a/frontend/tests/api/OpportunityListingApi.test.ts b/frontend/tests/api/OpportunityListingApi.test.ts index 2c64c8ada..82e0e1580 100644 --- a/frontend/tests/api/OpportunityListingApi.test.ts +++ b/frontend/tests/api/OpportunityListingApi.test.ts @@ -1,33 +1,38 @@ import OpportunityListingAPI from "src/app/api/OpportunityListingAPI"; import { OpportunityApiResponse } from "src/types/opportunity/opportunityResponseTypes"; -jest.mock("src/app/api/BaseApi"); +let opportunityListingAPI: OpportunityListingAPI; +const mockResponse = getValidMockResponse(); -describe("OpportunityListingAPI", () => { - const mockedRequest = jest.fn(); - const opportunityListingAPI = new OpportunityListingAPI(); +const mockedRequest = jest.fn(); - beforeAll(() => { - opportunityListingAPI.request = mockedRequest; +describe("OpportunityListingAPI", () => { + beforeEach(() => { + opportunityListingAPI = new OpportunityListingAPI(); + jest + .spyOn(opportunityListingAPI, "request") + .mockImplementation(mockedRequest); }); afterEach(() => { - mockedRequest.mockReset(); + jest.resetAllMocks(); }); - it("should return opportunity data for a valid ID", async () => { - const mockResponse: OpportunityApiResponse = getValidMockResponse(); + afterAll(() => { + jest.restoreAllMocks(); + }); - mockedRequest.mockResolvedValue(mockResponse); + it("instantiates correctly", () => { + expect(opportunityListingAPI.namespace).toEqual("opportunities"); + }); + it("should return opportunity data for a valid ID", async () => { + mockedRequest.mockImplementation(() => { + return Promise.resolve(mockResponse); + }); const result = await opportunityListingAPI.getOpportunityById(12345); - expect(mockedRequest).toHaveBeenCalledWith( - "GET", - opportunityListingAPI.basePath, - opportunityListingAPI.namespace, - "12345", - ); + expect(mockedRequest).toHaveBeenCalledWith("GET", "12345"); expect(result).toEqual(mockResponse); }); @@ -41,7 +46,7 @@ describe("OpportunityListingAPI", () => { }); }); -function getValidMockResponse() { +function getValidMockResponse(): OpportunityApiResponse { return { data: { agency: "US-ABC", diff --git a/frontend/tests/api/SearchOpportunityApi.test.ts b/frontend/tests/api/SearchOpportunityApi.test.ts index 96247f5e6..f30c77cac 100644 --- a/frontend/tests/api/SearchOpportunityApi.test.ts +++ b/frontend/tests/api/SearchOpportunityApi.test.ts @@ -1,113 +1,219 @@ -import SearchOpportunityAPI from "src/app/api/SearchOpportunityAPI"; +import SearchOpportunityAPI, { + buildFilters, + buildPagination, +} from "src/app/api/SearchOpportunityAPI"; import { QueryParamData } from "src/services/search/searchfetcher/SearchFetcher"; -import { SearchRequestBody } from "src/types/search/searchRequestTypes"; - -// mockFetch should match the SearchAPIResponse type structure -const mockFetch = ({ - response = { - data: [], - message: "Success", - pagination_info: { - order_by: "opportunity_id", - page_offset: 1, - page_size: 25, - sort_direction: "ascending", - total_pages: 1, - total_records: 0, - }, - status_code: 200, - errors: [], - warnings: [], +import { SearchFetcherActionType } from "src/types/search/searchRequestTypes"; + +const validMockedResponse = { + data: [], + message: "Success", + pagination_info: { + // TODO: the response order_by should + // by what the request had: opportunity_number + order_by: "opportunity_id", + page_offset: 1, + page_size: 25, + sort_direction: "ascending", + total_pages: 1, + total_records: 0, }, - ok = true, - status = 200, -}) => { - return jest.fn().mockResolvedValueOnce({ - json: jest.fn().mockResolvedValueOnce(response), - ok, - status, - }); + status_code: 200, + warnings: [], }; -describe("SearchOpportunityAPI", () => { - let searchApi: SearchOpportunityAPI; - const baseRequestHeaders = { - "Content-Type": "application/json", - }; +const searchProps: QueryParamData = { + page: 1, + status: new Set(["forecasted", "posted"]), + fundingInstrument: new Set(["grant", "cooperative_agreement"]), + agency: new Set(), + category: new Set(), + eligibility: new Set(), + query: "research", + sortby: "opportunityNumberAsc", +}; +let searchApi: SearchOpportunityAPI; +const mockedRequest = jest.fn(); + +describe("SearchOpportunityAPI", () => { beforeEach(() => { + searchApi = new SearchOpportunityAPI(); + jest.spyOn(searchApi, "request").mockImplementation(mockedRequest); + }); + afterEach(() => { jest.resetAllMocks(); + }); - searchApi = new SearchOpportunityAPI(); + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("instantiates correctly", () => { + expect(searchApi.namespace).toEqual("opportunities"); }); describe("searchOpportunities", () => { - beforeEach(() => { - global.fetch = mockFetch({}); - }); + it("calls request function with correct parameters", async () => { + mockedRequest.mockImplementation(() => + Promise.resolve(validMockedResponse), + ); + const result = await searchApi.searchOpportunities(searchProps); - it("sends POST request to search opportunities endpoint with query parameters", async () => { - const searchProps: QueryParamData = { - page: 1, - status: new Set(["forecasted", "posted"]), - fundingInstrument: new Set(["grant", "cooperative_agreement"]), - agency: new Set(), - category: new Set(), - eligibility: new Set(), - query: "research", - sortby: "opportunityNumberAsc", - }; - - const response = await searchApi.searchOpportunities(searchProps); - - const method = "POST"; - const headers = baseRequestHeaders; - - const requestBody: SearchRequestBody = { - pagination: { - order_by: "opportunity_number", // This should be the actual value being used in the API method - page_offset: 1, - page_size: 25, - sort_direction: "ascending", // or "descending" based on your sortby parameter - }, - filters: { - opportunity_status: { - one_of: Array.from(searchProps.status), + expect(mockedRequest).toHaveBeenCalledWith( + "POST", + "search", + searchProps, + { + pagination: { + order_by: "opportunity_number", // This should be the actual value being used in the API method + page_offset: 1, + page_size: 25, + sort_direction: "ascending", // or "descending" based on your sortby parameter }, - funding_instrument: { - one_of: Array.from(searchProps.fundingInstrument), + query: "research", + filters: { + opportunity_status: { + one_of: ["forecasted", "posted"], + }, + funding_instrument: { + one_of: ["grant", "cooperative_agreement"], + }, }, }, - query: searchProps.query || "", - }; - - const expectedUrl = `${searchApi.version}${searchApi.basePath}/${searchApi.namespace}/search`; - - expect(fetch).toHaveBeenCalledWith( - expectedUrl, - expect.objectContaining({ - method, - headers, - body: JSON.stringify(requestBody), - }), ); - expect(response).toEqual({ - data: [], - message: "Success", - pagination_info: { - // TODO: the response order_by should - // by what the request had: opportunity_number - order_by: "opportunity_id", - page_offset: 1, - page_size: 25, - sort_direction: "ascending", - total_pages: 1, - total_records: 0, + expect(result).toEqual(validMockedResponse); + }); + }); + + describe("buildFilters", () => { + it("maps all params to the correct filter names", () => { + const filters = buildFilters({ + ...searchProps, + ...{ + agency: new Set(["agency 1", "agency 2"]), + category: new Set(["category 1", "category 2"]), + eligibility: new Set(["applicant type 1", "applicant type 2"]), }, - status_code: 200, - warnings: [], }); + expect(filters.opportunity_status).toEqual({ + one_of: ["forecasted", "posted"], + }); + expect(filters.funding_instrument).toEqual({ + one_of: ["grant", "cooperative_agreement"], + }); + expect(filters.applicant_type).toEqual({ + one_of: ["applicant type 1", "applicant type 2"], + }); + expect(filters.agency).toEqual({ + one_of: ["agency 1", "agency 2"], + }); + expect(filters.funding_category).toEqual({ + one_of: ["category 1", "category 2"], + }); + }); + it("does not add filters where params are absent", () => { + const filters = buildFilters(searchProps); + expect(filters.opportunity_status).toEqual({ + one_of: ["forecasted", "posted"], + }); + expect(filters.funding_instrument).toEqual({ + one_of: ["grant", "cooperative_agreement"], + }); + expect(filters.applicant_type).toEqual(undefined); + expect(filters.agency).toEqual(undefined); + expect(filters.funding_category).toEqual(undefined); + }); + }); + + describe("buildPagination", () => { + it("builds correct pagination with defaults", () => { + const pagination = buildPagination({ + ...searchProps, + ...{ page: 5, sortby: null }, + }); + + expect(pagination.order_by).toEqual("post_date"); + expect(pagination.page_offset).toEqual(5); + expect(pagination.sort_direction).toEqual("descending"); + }); + + it("builds correct offset based on action type and field changed", () => { + const pagination = buildPagination({ + ...searchProps, + ...{ page: 5, actionType: SearchFetcherActionType.Update }, + }); + + expect(pagination.page_offset).toEqual(1); + + const secondPagination = buildPagination({ + ...searchProps, + ...{ page: 5, actionType: SearchFetcherActionType.InitialLoad }, + }); + + expect(secondPagination.page_offset).toEqual(5); + + const thirdPagination = buildPagination({ + ...searchProps, + ...{ + page: 5, + actionType: SearchFetcherActionType.Update, + fieldChanged: "pagination", + }, + }); + + expect(thirdPagination.page_offset).toEqual(5); + + const fourthPagination = buildPagination({ + ...searchProps, + ...{ + page: 5, + actionType: SearchFetcherActionType.Update, + fieldChanged: "not_pagination", + }, + }); + + expect(fourthPagination.page_offset).toEqual(1); + }); + + it("builds correct order_by based on sortby", () => { + const pagination = buildPagination({ + ...searchProps, + ...{ sortby: "closeDate" }, + }); + + expect(pagination.order_by).toEqual("close_date"); + + const secondPagination = buildPagination({ + ...searchProps, + ...{ sortby: "postedDate" }, + }); + + expect(secondPagination.order_by).toEqual("post_date"); + }); + + it("builds correct sort_direction based on sortby", () => { + const pagination = buildPagination({ + ...searchProps, + ...{ sortby: "opportunityNumberDesc" }, + }); + + expect(pagination.sort_direction).toEqual("descending"); + + const secondPagination = buildPagination({ + ...searchProps, + ...{ sortby: "postedDateAsc" }, + }); + + expect(secondPagination.sort_direction).toEqual("ascending"); + + const thirdPagination = buildPagination({ + ...searchProps, + ...{ sortby: null }, + }); + + expect(thirdPagination.sort_direction).toEqual("descending"); }); }); }); diff --git a/frontend/tests/e2e/newsletter.spec.ts b/frontend/tests/e2e/newsletter.spec.ts index 55215c1b5..dca5d4080 100644 --- a/frontend/tests/e2e/newsletter.spec.ts +++ b/frontend/tests/e2e/newsletter.spec.ts @@ -1,6 +1,8 @@ /* eslint-disable testing-library/prefer-screen-queries */ import { expect, test } from "@playwright/test"; +import { generateRandomString } from "./search/searchSpecUtil"; + test.beforeEach(async ({ page }) => { await page.goto("/subscribe"); }); @@ -32,7 +34,9 @@ test("successful signup", async ({ page }) => { ); // Fill out form - await page.getByLabel("First Name (required)").fill("Apple"); + await page + .getByLabel("First Name (required)") + .fill(generateRandomString([10])); await page.getByLabel("Email (required)").fill("name@example.com"); await page.getByRole("button", { name: /subscribe/i }).click(); @@ -54,7 +58,9 @@ test("error during signup", async ({ page }) => { ); // Fill out form - await page.getByLabel("First Name (required)").fill("Apple"); + await page + .getByLabel("First Name (required)") + .fill(generateRandomString([10])); await page.getByLabel("Email (required)").fill("name@example.com"); await page.getByRole("button", { name: /subscribe/i }).click(); diff --git a/frontend/tests/e2e/search/search-loading.spec.ts b/frontend/tests/e2e/search/search-loading.spec.ts index a9d163e9b..bef38bc94 100644 --- a/frontend/tests/e2e/search/search-loading.spec.ts +++ b/frontend/tests/e2e/search/search-loading.spec.ts @@ -1,29 +1,25 @@ -import { expect, Page, test } from "@playwright/test"; -import { BrowserContextOptions } from "playwright-core"; +import { expect, test } from "@playwright/test"; +import { chromium } from "playwright-core"; +import { + fillSearchInputAndSubmit, + generateRandomString, +} from "tests/e2e/search/searchSpecUtil"; -import { fillSearchInputAndSubmit } from "./searchSpecUtil"; +test.describe("Search page tests", () => { + test("should show and hide loading state", async () => { + const searchTerm = generateRandomString([4, 5]); + const searchTerm2 = generateRandomString([8]); -interface PageProps { - page: Page; - browserName?: string; - contextOptions?: BrowserContextOptions; -} + const browser = await chromium.launch({ slowMo: 100 }); -test.describe("Search page tests", () => { - test.beforeEach(async ({ page }: PageProps) => { - // Navigate to the search page with the feature flag set + const page = await browser.newPage(); await page.goto("/search?_ff=showSearchV0:true"); - }); + const loadingIndicator = page.getByTestId("loading-message"); - test("should show and hide loading state", async ({ page }: PageProps) => { - const searchTerm = "advanced"; await fillSearchInputAndSubmit(searchTerm, page); - - const loadingIndicator = page.getByTestId("loading-message"); await expect(loadingIndicator).toBeVisible(); await expect(loadingIndicator).toBeHidden(); - const searchTerm2 = "agency"; await fillSearchInputAndSubmit(searchTerm2, page); await expect(loadingIndicator).toBeVisible(); await expect(loadingIndicator).toBeHidden(); diff --git a/frontend/tests/e2e/search/search-no-results.spec.ts b/frontend/tests/e2e/search/search-no-results.spec.ts index bf446bd77..913797510 100644 --- a/frontend/tests/e2e/search/search-no-results.spec.ts +++ b/frontend/tests/e2e/search/search-no-results.spec.ts @@ -4,6 +4,7 @@ import { BrowserContextOptions } from "playwright-core"; import { expectURLContainsQueryParam, fillSearchInputAndSubmit, + generateRandomString, } from "./searchSpecUtil"; interface PageProps { @@ -21,7 +22,7 @@ test.describe("Search page tests", () => { test("should return 0 results when searching for obscure term", async ({ page, }: PageProps) => { - const searchTerm = "0resultearch"; + const searchTerm = generateRandomString([10]); await fillSearchInputAndSubmit(searchTerm, page); await new Promise((resolve) => setTimeout(resolve, 3250)); diff --git a/frontend/tests/e2e/search/searchSpecUtil.ts b/frontend/tests/e2e/search/searchSpecUtil.ts index 41a669cdf..f401bc3e4 100644 --- a/frontend/tests/e2e/search/searchSpecUtil.ts +++ b/frontend/tests/e2e/search/searchSpecUtil.ts @@ -14,6 +14,26 @@ export async function fillSearchInputAndSubmit(term: string, page: Page) { await page.click(".usa-search > button[type='submit']"); } +const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +// adapted from https://stackoverflow.com/a/1349426 +export const generateRandomString = (desiredPattern: number[]) => { + const numberOfPossibleCharacters = characters.length; + return desiredPattern.reduce((randomString, numberOfCharacters, index) => { + let counter = 0; + while (counter < numberOfCharacters) { + randomString += characters.charAt( + Math.floor(Math.random() * numberOfPossibleCharacters), + ); + counter += 1; + } + if (index < desiredPattern.length - 1) { + randomString += " "; + } + return randomString; + }, ""); +}; + export function expectURLContainsQueryParam( page: Page, queryParamName: string,