From e475e7c9005f65497677b48129669f2ed8467c33 Mon Sep 17 00:00:00 2001
From: doug-s-nava <92806979+doug-s-nava@users.noreply.github.com>
Date: Mon, 3 Feb 2025 13:17:11 -0500
Subject: [PATCH] [Issue #3518] download search results (#3689)
* adds a button to the search page that downloads search results in csv format
* refactors fetch function service and helpers to be more flexible in order to allow a non-json response
* updates some flaky e2e tests
---
.../app/[locale]/opportunity/[id]/page.tsx | 8 +-
frontend/src/app/[locale]/search/error.tsx | 4 +-
frontend/src/app/api/search/export/route.ts | 36 +++++
.../opportunity/OpportunityDocuments.tsx | 11 +-
.../search/ExportSearchResultsButton.tsx | 39 ++++++
.../components/search/SearchPagination.tsx | 7 +-
.../search/SearchPaginationFetch.tsx | 4 +-
.../src/components/search/SearchResults.tsx | 29 ++--
frontend/src/i18n/messages/en/index.ts | 3 +
.../featureFlags/FeatureFlagManager.ts | 4 +-
frontend/src/services/fetch/fetcherHelpers.ts | 69 +---------
.../clientSearchResultsDownloadFetcher.ts | 34 +++++
.../src/services/fetch/fetchers/fetchers.ts | 60 +++++---
.../fetch/fetchers/opportunityFetcher.ts | 11 ++
.../services/fetch/fetchers/searchFetcher.ts | 46 ++++++-
.../src/types/search/searchRequestTypes.ts | 1 +
frontend/src/types/searchRequestURLTypes.ts | 2 +-
frontend/src/types/uiTypes.ts | 4 +-
frontend/src/utils/dateUtil.ts | 9 ++
.../convertSearchParamsToProperTypes.ts | 4 +-
.../api/auth/search/export/route.test.ts | 58 ++++++++
frontend/tests/api/auth/session/route.test.ts | 2 +-
.../search/ExportSearchResultsButton.test.tsx | 39 ++++++
.../tests/e2e/search/search-download.spec.ts | 19 +++
.../tests/e2e/search/search-loading.spec.ts | 4 +-
frontend/tests/e2e/search/search.spec.ts | 8 +-
.../services/fetch/FetcherHelpers.test.ts | 129 ++----------------
.../tests/services/fetch/Fetchers.test.ts | 94 ++++++++-----
.../fetch/fetchers/SearchFetcher.test.ts | 59 ++++++--
...clientSearchResultsDownloadFetcher.test.ts | 76 +++++++++++
.../fetch/fetchers/opportunityFetcher.test.ts | 28 ++++
31 files changed, 608 insertions(+), 293 deletions(-)
create mode 100644 frontend/src/app/api/search/export/route.ts
create mode 100644 frontend/src/components/search/ExportSearchResultsButton.tsx
create mode 100644 frontend/src/services/fetch/fetchers/clientSearchResultsDownloadFetcher.ts
create mode 100644 frontend/src/services/fetch/fetchers/opportunityFetcher.ts
create mode 100644 frontend/tests/api/auth/search/export/route.test.ts
create mode 100644 frontend/tests/components/search/ExportSearchResultsButton.test.tsx
create mode 100644 frontend/tests/e2e/search/search-download.spec.ts
create mode 100644 frontend/tests/services/fetch/fetchers/clientSearchResultsDownloadFetcher.test.ts
create mode 100644 frontend/tests/services/fetch/fetchers/opportunityFetcher.test.ts
diff --git a/frontend/src/app/[locale]/opportunity/[id]/page.tsx b/frontend/src/app/[locale]/opportunity/[id]/page.tsx
index c4d62acee..1afaf343a 100644
--- a/frontend/src/app/[locale]/opportunity/[id]/page.tsx
+++ b/frontend/src/app/[locale]/opportunity/[id]/page.tsx
@@ -3,7 +3,7 @@ import NotFound from "src/app/[locale]/not-found";
import { OPPORTUNITY_CRUMBS } from "src/constants/breadcrumbs";
import { ApiRequestError, parseErrorStatus } from "src/errors";
import withFeatureFlag from "src/hoc/withFeatureFlag";
-import { fetchOpportunity } from "src/services/fetch/fetchers/fetchers";
+import { getOpportunityDetails } from "src/services/fetch/fetchers/opportunityFetcher";
import { Opportunity } from "src/types/opportunity/opportunityResponseTypes";
import { WithFeatureFlagProps } from "src/types/uiTypes";
@@ -38,9 +38,7 @@ export async function generateMetadata({
const t = await getTranslations({ locale });
let title = `${t("OpportunityListing.page_title")}`;
try {
- const { data: opportunityData } = await fetchOpportunity({
- subPath: id,
- });
+ const { data: opportunityData } = await getOpportunityDetails(id);
title = `${t("OpportunityListing.page_title")} - ${opportunityData.opportunity_title}`;
} catch (error) {
console.error("Failed to render page title due to API error", error);
@@ -106,7 +104,7 @@ async function OpportunityListing({ params }: OpportunityListingProps) {
let opportunityData = {} as Opportunity;
try {
- const response = await fetchOpportunity({ subPath: id });
+ const response = await getOpportunityDetails(id);
opportunityData = response.data;
} catch (error) {
if (parseErrorStatus(error as ApiRequestError) === 404) {
diff --git a/frontend/src/app/[locale]/search/error.tsx b/frontend/src/app/[locale]/search/error.tsx
index 261eebea7..be38ec2e1 100644
--- a/frontend/src/app/[locale]/search/error.tsx
+++ b/frontend/src/app/[locale]/search/error.tsx
@@ -3,7 +3,7 @@
import QueryProvider from "src/app/[locale]/search/QueryProvider";
import { usePrevious } from "src/hooks/usePrevious";
import { FrontendErrorDetails } from "src/types/apiResponseTypes";
-import { ServerSideSearchParams } from "src/types/searchRequestURLTypes";
+import { OptionalStringDict } from "src/types/searchRequestURLTypes";
import { Breakpoints, ErrorProps } from "src/types/uiTypes";
import { convertSearchParamsToProperTypes } from "src/utils/search/convertSearchParamsToProperTypes";
@@ -19,7 +19,7 @@ import ServerErrorAlert from "src/components/ServerErrorAlert";
export interface ParsedError {
message: string;
- searchInputs: ServerSideSearchParams;
+ searchInputs: OptionalStringDict;
status: number;
type: string;
details?: FrontendErrorDetails;
diff --git a/frontend/src/app/api/search/export/route.ts b/frontend/src/app/api/search/export/route.ts
new file mode 100644
index 000000000..5dfeca1df
--- /dev/null
+++ b/frontend/src/app/api/search/export/route.ts
@@ -0,0 +1,36 @@
+import { downloadOpportunities } from "src/services/fetch/fetchers/searchFetcher";
+import { convertSearchParamsToProperTypes } from "src/utils/search/convertSearchParamsToProperTypes";
+
+import { NextRequest, NextResponse } from "next/server";
+
+export const revalidate = 0;
+
+/*
+ the data flow here goes like:
+
+ ExportSearchResultsButton click ->
+ /export route ->
+ downloadOpportunities ->
+ fetchOpportunitySearch ->
+ ExportSearchResultsButton (handle response by blobbing it to the location) -> user's file system
+
+*/
+
+export async function GET(request: NextRequest) {
+ try {
+ const searchParams = convertSearchParamsToProperTypes(
+ Object.fromEntries(request.nextUrl.searchParams.entries().toArray()),
+ );
+ const apiResponseBody = await downloadOpportunities(searchParams);
+ return new NextResponse(apiResponseBody, {
+ headers: {
+ "Content-Type": "text/csv",
+ "Content-Disposition":
+ "attachment; filename=simpler-grants-search-results.csv",
+ },
+ });
+ } catch (e) {
+ console.error("Error downloading search results", e);
+ throw e;
+ }
+}
diff --git a/frontend/src/components/opportunity/OpportunityDocuments.tsx b/frontend/src/components/opportunity/OpportunityDocuments.tsx
index ecf03d7a4..ef87bb6b3 100644
--- a/frontend/src/components/opportunity/OpportunityDocuments.tsx
+++ b/frontend/src/components/opportunity/OpportunityDocuments.tsx
@@ -1,6 +1,4 @@
-import dayjs from "dayjs";
-import advancedFormat from "dayjs/plugin/advancedFormat";
-import timezone from "dayjs/plugin/timezone";
+import { getConfiguredDayJs } from "src/utils/dateUtil";
import { useTranslations } from "next-intl";
import { Link, Table } from "@trussworks/react-uswds";
@@ -16,9 +14,6 @@ interface OpportunityDocumentsProps {
documents: OpportunityDocument[];
}
-dayjs.extend(advancedFormat);
-dayjs.extend(timezone);
-
const DocumentTable = ({ documents }: OpportunityDocumentsProps) => {
const t = useTranslations("OpportunityListing.documents");
@@ -47,7 +42,9 @@ const DocumentTable = ({ documents }: OpportunityDocumentsProps) => {
{/* https://day.js.org/docs/en/display/format */}
- {dayjs(document.updated_at).format("MMM D, YYYY hh:mm A z")}
+ {getConfiguredDayJs()(document.updated_at).format(
+ "MMM D, YYYY hh:mm A z",
+ )}
|
))}
diff --git a/frontend/src/components/search/ExportSearchResultsButton.tsx b/frontend/src/components/search/ExportSearchResultsButton.tsx
new file mode 100644
index 000000000..c7ebc3f4b
--- /dev/null
+++ b/frontend/src/components/search/ExportSearchResultsButton.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { downloadSearchResultsCSV } from "src/services/fetch/fetchers/clientSearchResultsDownloadFetcher";
+
+import { useTranslations } from "next-intl";
+import { useSearchParams } from "next/navigation";
+import { useCallback } from "react";
+import { Button } from "@trussworks/react-uswds";
+
+import { USWDSIcon } from "src/components/USWDSIcon";
+
+export function ExportSearchResultsButton() {
+ const t = useTranslations("Search.exportButton");
+ const searchParams = useSearchParams();
+
+ const downloadSearchResults = useCallback(() => {
+ // catch included here to satisfy linter
+ downloadSearchResultsCSV(searchParams).catch((e) => {
+ throw e;
+ });
+ }, [searchParams]);
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/search/SearchPagination.tsx b/frontend/src/components/search/SearchPagination.tsx
index 4c5e45637..30c793650 100644
--- a/frontend/src/components/search/SearchPagination.tsx
+++ b/frontend/src/components/search/SearchPagination.tsx
@@ -63,9 +63,14 @@ export default function SearchPagination({
const pageCount = totalPages || Number(totalPagesFromQuery);
return (
-
+
{totalResults !== "0" && pageCount > 0 && (
;
// Determines whether clicking on pager items causes a scroll to the top of the search
// results. Created so the bottom pager can scroll.
- scroll: boolean;
+ scroll?: boolean;
page: number;
query?: string | null;
}
@@ -17,7 +17,7 @@ export default async function SearchPaginationFetch({
page,
query,
searchResultsPromise,
- scroll,
+ scroll = false,
}: SearchPaginationProps) {
const searchResults = await searchResultsPromise;
const totalPages = searchResults.pagination_info?.total_pages;
diff --git a/frontend/src/components/search/SearchResults.tsx b/frontend/src/components/search/SearchResults.tsx
index bb688c9a3..fd8c1ae0c 100644
--- a/frontend/src/components/search/SearchResults.tsx
+++ b/frontend/src/components/search/SearchResults.tsx
@@ -9,6 +9,7 @@ import SearchPaginationFetch from "src/components/search/SearchPaginationFetch";
import SearchResultsHeader from "src/components/search/SearchResultsHeader";
import SearchResultsHeaderFetch from "src/components/search/SearchResultsHeaderFetch";
import SearchResultsListFetch from "src/components/search/SearchResultsListFetch";
+import { ExportSearchResultsButton } from "./ExportSearchResultsButton";
export default function SearchResults({
searchParams,
@@ -36,19 +37,21 @@ export default function SearchResults({
/>
-
- }
- >
-
-
+
+
+
+ }
+ >
+
+
+
}>
diff --git a/frontend/src/i18n/messages/en/index.ts b/frontend/src/i18n/messages/en/index.ts
index 3f40de6db..ce238f204 100644
--- a/frontend/src/i18n/messages/en/index.ts
+++ b/frontend/src/i18n/messages/en/index.ts
@@ -621,6 +621,9 @@ export const messages = {
generic_error_cta: "Please try your search again.",
validationError: "Search Validation Error",
tooLongError: "Search terms must be no longer than 100 characters.",
+ exportButton: {
+ title: "Export results",
+ },
},
Maintenance: {
heading: "Simpler.Grants.gov Is Currently Undergoing Maintenance",
diff --git a/frontend/src/services/featureFlags/FeatureFlagManager.ts b/frontend/src/services/featureFlags/FeatureFlagManager.ts
index 11c9baa7b..27ce3c188 100644
--- a/frontend/src/services/featureFlags/FeatureFlagManager.ts
+++ b/frontend/src/services/featureFlags/FeatureFlagManager.ts
@@ -14,7 +14,7 @@ import {
parseFeatureFlagsFromString,
setCookie,
} from "src/services/featureFlags/featureFlagHelpers";
-import { ServerSideSearchParams } from "src/types/searchRequestURLTypes";
+import { OptionalStringDict } from "src/types/searchRequestURLTypes";
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
import { NextRequest, NextResponse } from "next/server";
@@ -84,7 +84,7 @@ export class FeatureFlagsManager {
isFeatureEnabled(
name: string,
cookies: NextRequest["cookies"] | ReadonlyRequestCookies,
- searchParams?: ServerSideSearchParams,
+ searchParams?: OptionalStringDict,
): boolean {
if (!isValidFeatureFlag(name)) {
throw new Error(`\`${name}\` is not a valid feature flag`);
diff --git a/frontend/src/services/fetch/fetcherHelpers.ts b/frontend/src/services/fetch/fetcherHelpers.ts
index 3f863ef1c..5419fd6bd 100644
--- a/frontend/src/services/fetch/fetcherHelpers.ts
+++ b/frontend/src/services/fetch/fetcherHelpers.ts
@@ -1,6 +1,6 @@
import "server-only";
-import { compact, isEmpty } from "lodash";
+import { compact } from "lodash";
import { environment } from "src/constants/environments";
import {
ApiRequestError,
@@ -27,7 +27,6 @@ export interface HeadersDict {
}
// Configuration of headers to send with all requests
-// Can include feature flags in child classes
export function getDefaultHeaders(): HeadersDict {
const headers: HeadersDict = {};
@@ -38,32 +37,6 @@ export function getDefaultHeaders(): HeadersDict {
return headers;
}
-/**
- * Send a request and handle the response
- * @param queryParamData: note that this is only used in error handling in order to help restore original page state
- */
-export async function sendRequest
(
- url: string,
- fetchOptions: RequestInit,
- queryParamData?: QueryParamData,
-): Promise {
- let response;
- let responseBody;
- try {
- response = await fetch(url, fetchOptions);
- 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.
- throw fetchErrorToNetworkError(error, queryParamData);
- }
- if (!response.ok) {
- handleNotOkResponse(responseBody, url, queryParamData);
- }
-
- return responseBody;
-}
-
export function createRequestUrl(
method: ApiMethod,
basePath: string,
@@ -115,7 +88,7 @@ export function createRequestBody(
/**
* Handle request errors
*/
-function fetchErrorToNetworkError(
+export function fetchErrorToNetworkError(
error: unknown,
searchInputs?: QueryParamData,
) {
@@ -127,42 +100,12 @@ function fetchErrorToNetworkError(
: new NetworkError(error);
}
-// note that this will pass along filter inputs in order to maintain the state
-// of the page when relaying an error, but anything passed in the body of the request,
-// such as keyword search query will not be included
-function handleNotOkResponse(
- response: APIResponse,
- url: string,
- searchInputs?: QueryParamData,
-) {
- const { errors } = response;
- if (isEmpty(errors)) {
- // No detailed errors provided, throw generic error based on status code
- throwError(response, url, searchInputs);
- } else {
- if (errors) {
- const firstError = errors[0];
- throwError(response, url, searchInputs, firstError);
- }
- }
-}
+export const throwError = (responseBody: APIResponse, url: string) => {
+ const { status_code = 0, message = "", errors } = responseBody;
+ console.error(`API request error at ${url} (${status_code}): ${message}`);
-export const throwError = (
- response: APIResponse,
- url: string,
- searchInputs?: QueryParamData,
- firstError?: unknown,
-) => {
- const { status_code = 0, message = "" } = response;
- console.error(
- `API request error at ${url} (${status_code}): ${message}`,
- searchInputs,
- );
+ const details = (errors && errors[0]) || {};
- const details = {
- searchInputs,
- ...(firstError || {}),
- };
switch (status_code) {
case 400:
throw new BadRequestError(message, details);
diff --git a/frontend/src/services/fetch/fetchers/clientSearchResultsDownloadFetcher.ts b/frontend/src/services/fetch/fetchers/clientSearchResultsDownloadFetcher.ts
new file mode 100644
index 000000000..dbe05fa51
--- /dev/null
+++ b/frontend/src/services/fetch/fetchers/clientSearchResultsDownloadFetcher.ts
@@ -0,0 +1,34 @@
+import { getConfiguredDayJs } from "src/utils/dateUtil";
+
+import { ReadonlyURLSearchParams } from "next/navigation";
+
+// downloads csv, then blobs it out to allow browser to download it
+// note that this could be handled by just pointing the browser location at the URL
+// but we'd lose any ability for graceful error handling that way
+export const downloadSearchResultsCSV = async (
+ searchParams: ReadonlyURLSearchParams,
+) => {
+ try {
+ const response = await fetch(
+ `/api/search/export?${searchParams.toString()}`,
+ );
+
+ if (!response.ok) {
+ throw new Error(`Unsuccessful csv download. ${response.status}`);
+ }
+ const csvBlob = await response.blob();
+ location.assign(
+ URL.createObjectURL(
+ new File(
+ [csvBlob],
+ `grants-search-${getConfiguredDayJs()(new Date()).format("YYYYMMDDHHmm")}.csv`,
+ {
+ type: "data:text/csv",
+ },
+ ),
+ ),
+ );
+ } catch (e) {
+ console.error(e);
+ }
+};
diff --git a/frontend/src/services/fetch/fetchers/fetchers.ts b/frontend/src/services/fetch/fetchers/fetchers.ts
index 36c9fe574..8cfad085a 100644
--- a/frontend/src/services/fetch/fetchers/fetchers.ts
+++ b/frontend/src/services/fetch/fetchers/fetchers.ts
@@ -1,5 +1,6 @@
import "server-only";
+import { ApiRequestError } from "src/errors";
import {
EndpointConfig,
fetchOpportunityEndpoint,
@@ -9,22 +10,20 @@ import {
import {
createRequestBody,
createRequestUrl,
+ fetchErrorToNetworkError,
getDefaultHeaders,
HeadersDict,
JSONRequestBody,
- sendRequest,
+ throwError,
} from "src/services/fetch/fetcherHelpers";
import { APIResponse } from "src/types/apiResponseTypes";
-import { OpportunityApiResponse } from "src/types/opportunity/opportunityResponseTypes";
-import { QueryParamData } from "src/types/search/searchRequestTypes";
-import { SearchAPIResponse } from "src/types/search/searchResponseTypes";
import { cache } from "react";
// returns a function which can be used to make a request to an endpoint defined in the passed config
// note that subpath is dynamic per request, any static paths at this point would need to be included in the namespace
-// making this more flexible is a future todo
-export function requesterForEndpoint({
+// returns the full api response, dealing with parsing the body will happen explicitly for each request type
+export function requesterForEndpoint({
method,
basePath,
version,
@@ -33,12 +32,11 @@ export function requesterForEndpoint({
return async function (
options: {
subPath?: string;
- queryParamData?: QueryParamData; // only used for error handling purposes
body?: JSONRequestBody;
additionalHeaders?: HeadersDict;
} = {},
- ): Promise {
- const { additionalHeaders = {}, body, queryParamData, subPath } = options;
+ ): Promise {
+ const { additionalHeaders = {}, body, subPath } = options;
const url = createRequestUrl(
method,
basePath,
@@ -52,27 +50,51 @@ export function requesterForEndpoint({
...additionalHeaders,
};
- const response = await sendRequest(
- url,
- {
+ let response;
+ try {
+ response = await fetch(url, {
body: method === "GET" || !body ? null : createRequestBody(body),
headers,
method,
- },
- queryParamData,
- );
+ });
+ } catch (error) {
+ // API most likely down, but also possibly an error setting up or sending a request
+ // or parsing the response.
+ throw fetchErrorToNetworkError(error);
+ }
+
+ if (
+ !response.ok &&
+ response.headers.get("Content-Type") === "application/json"
+ ) {
+ // we can assume this is serializable json based on the response header, but we'll catch anyway
+ let jsonBody;
+ try {
+ jsonBody = (await response.json()) as APIResponse;
+ } catch (e) {
+ throw new Error(
+ `bad Json from error response at ${url} with status code ${response.status}`,
+ );
+ }
+ return throwError(jsonBody, url);
+ } else if (!response.ok) {
+ throw new ApiRequestError(
+ `unable to fetch ${url}`,
+ "APIRequestError",
+ response.status,
+ );
+ }
return response;
};
}
export const fetchOpportunity = cache(
- requesterForEndpoint(fetchOpportunityEndpoint),
+ requesterForEndpoint(fetchOpportunityEndpoint),
);
-export const fetchOpportunitySearch = requesterForEndpoint(
+export const fetchOpportunitySearch = requesterForEndpoint(
opportunitySearchEndpoint,
);
-export const postUserLogout =
- requesterForEndpoint(userLogoutEndpoint);
+export const postUserLogout = requesterForEndpoint(userLogoutEndpoint);
diff --git a/frontend/src/services/fetch/fetchers/opportunityFetcher.ts b/frontend/src/services/fetch/fetchers/opportunityFetcher.ts
new file mode 100644
index 000000000..f25a797a4
--- /dev/null
+++ b/frontend/src/services/fetch/fetchers/opportunityFetcher.ts
@@ -0,0 +1,11 @@
+import { OpportunityApiResponse } from "src/types/opportunity/opportunityResponseTypes";
+
+import { fetchOpportunity } from "./fetchers";
+
+export const getOpportunityDetails = async (
+ id: string,
+): Promise => {
+ const response = await fetchOpportunity({ subPath: id });
+ const responseBody = (await response.json()) as OpportunityApiResponse;
+ return responseBody;
+};
diff --git a/frontend/src/services/fetch/fetchers/searchFetcher.ts b/frontend/src/services/fetch/fetchers/searchFetcher.ts
index 62956dbfe..a1f1a1801 100644
--- a/frontend/src/services/fetch/fetchers/searchFetcher.ts
+++ b/frontend/src/services/fetch/fetchers/searchFetcher.ts
@@ -9,6 +9,7 @@ import {
SearchFilterRequestBody,
SearchRequestBody,
} from "src/types/search/searchRequestTypes";
+import { SearchAPIResponse } from "src/types/search/searchResponseTypes";
const orderByFieldLookup = {
relevancy: "relevancy",
@@ -52,16 +53,51 @@ export const searchForOpportunities = async (searchInputs: QueryParamData) => {
const response = await fetchOpportunitySearch({
body: requestBody,
- queryParamData: searchInputs,
});
- response.actionType = searchInputs.actionType;
- response.fieldChanged = searchInputs.fieldChanged;
+ const responseBody = (await response.json()) as SearchAPIResponse;
- if (!response.data) {
+ responseBody.actionType = searchInputs.actionType;
+ responseBody.fieldChanged = searchInputs.fieldChanged;
+
+ if (!responseBody.data) {
throw new Error("No data returned from Opportunity Search API");
}
- return response;
+ return responseBody;
+};
+
+// this is very similar to `searchForOpportunities`, but
+// * hardcodes some pagination params
+// * sets format param = 'csv'
+// * response body on success is not json, so does not parse it
+export const downloadOpportunities = async (
+ searchInputs: QueryParamData,
+): Promise>> => {
+ const { query } = searchInputs;
+ const filters = buildFilters(searchInputs);
+ const pagination = buildPagination(searchInputs);
+
+ const requestBody: SearchRequestBody = {
+ pagination: { ...pagination, page_size: 5000, page_offset: 1 },
+ };
+
+ if (Object.keys(filters).length > 0) {
+ requestBody.filters = filters;
+ }
+
+ if (query) {
+ requestBody.query = query;
+ }
+
+ const response = await fetchOpportunitySearch({
+ body: { ...requestBody, format: "csv" },
+ });
+
+ if (!response.body) {
+ throw new Error("No data returned from Opportunity Search API export");
+ }
+
+ return response.body;
};
// Translate frontend filter param names to expected backend parameter names, and use one_of syntax
diff --git a/frontend/src/types/search/searchRequestTypes.ts b/frontend/src/types/search/searchRequestTypes.ts
index bafb77406..eab0172a8 100644
--- a/frontend/src/types/search/searchRequestTypes.ts
+++ b/frontend/src/types/search/searchRequestTypes.ts
@@ -26,6 +26,7 @@ export type SearchRequestBody = {
pagination: PaginationRequestBody;
filters?: SearchFilterRequestBody;
query?: string;
+ format?: string;
};
export enum SearchFetcherActionType {
diff --git a/frontend/src/types/searchRequestURLTypes.ts b/frontend/src/types/searchRequestURLTypes.ts
index b8a397079..d99744d7a 100644
--- a/frontend/src/types/searchRequestURLTypes.ts
+++ b/frontend/src/types/searchRequestURLTypes.ts
@@ -5,6 +5,6 @@ export interface ServerSideRouteParams {
}
// Query param prop for app router server-side pages
-export interface ServerSideSearchParams {
+export interface OptionalStringDict {
[key: string]: string | undefined;
}
diff --git a/frontend/src/types/uiTypes.ts b/frontend/src/types/uiTypes.ts
index 5ff546b55..895022bc9 100644
--- a/frontend/src/types/uiTypes.ts
+++ b/frontend/src/types/uiTypes.ts
@@ -1,4 +1,4 @@
-import { ServerSideSearchParams } from "src/types/searchRequestURLTypes";
+import { OptionalStringDict } from "src/types/searchRequestURLTypes";
export enum Breakpoints {
CARD = "card",
@@ -13,7 +13,7 @@ export enum Breakpoints {
}
export type WithFeatureFlagProps = {
- searchParams: Promise;
+ searchParams: Promise;
};
export interface ErrorProps {
diff --git a/frontend/src/utils/dateUtil.ts b/frontend/src/utils/dateUtil.ts
index 47eea08d1..c0fa7afca 100644
--- a/frontend/src/utils/dateUtil.ts
+++ b/frontend/src/utils/dateUtil.ts
@@ -1,3 +1,10 @@
+import dayjs from "dayjs";
+import advancedFormat from "dayjs/plugin/advancedFormat";
+import timezone from "dayjs/plugin/timezone";
+
+dayjs.extend(timezone);
+dayjs.extend(advancedFormat);
+
// Convert "2024-02-21" to "February 21, 2024"
export function formatDate(dateStr: string | null): string {
if (!dateStr || dateStr.length !== 10) {
@@ -23,3 +30,5 @@ export function formatDate(dateStr: string | null): string {
};
return date.toLocaleDateString("en-US", options);
}
+
+export const getConfiguredDayJs = () => dayjs;
diff --git a/frontend/src/utils/search/convertSearchParamsToProperTypes.ts b/frontend/src/utils/search/convertSearchParamsToProperTypes.ts
index 1ece9b231..ce159c0d9 100644
--- a/frontend/src/utils/search/convertSearchParamsToProperTypes.ts
+++ b/frontend/src/utils/search/convertSearchParamsToProperTypes.ts
@@ -5,7 +5,7 @@ import {
SearchFetcherActionType,
SortOptions,
} from "src/types/search/searchRequestTypes";
-import { ServerSideSearchParams } from "src/types/searchRequestURLTypes";
+import { OptionalStringDict } from "src/types/searchRequestURLTypes";
// Search params (query string) coming from the request URL into the server
// can be a string, string[], or undefined.
@@ -13,7 +13,7 @@ import { ServerSideSearchParams } from "src/types/searchRequestURLTypes";
// The above doesn't seem to still be true, should we update? - DWS
export function convertSearchParamsToProperTypes(
- params: ServerSideSearchParams,
+ params: OptionalStringDict,
): QueryParamData {
return {
...params,
diff --git a/frontend/tests/api/auth/search/export/route.test.ts b/frontend/tests/api/auth/search/export/route.test.ts
new file mode 100644
index 000000000..8ced08798
--- /dev/null
+++ b/frontend/tests/api/auth/search/export/route.test.ts
@@ -0,0 +1,58 @@
+/**
+ * @jest-environment node
+ */
+
+import { GET } from "src/app/api/search/export/route";
+
+import { NextRequest } from "next/server";
+
+const fakeRequestForSearchParams = (searchParams: string) => {
+ return {
+ nextUrl: {
+ searchParams: new URLSearchParams(searchParams),
+ },
+ } as NextRequest;
+};
+
+const fakeConvertedParams = {
+ actionType: "initialLoad",
+ agency: new Set(["EPA"]),
+ category: new Set(),
+ eligibility: new Set(),
+ fundingInstrument: new Set(),
+ page: 1,
+ query: "",
+ sortby: null,
+ status: new Set(["closed"]),
+};
+
+const mockDownloadOpportunities = jest.fn((params: unknown): unknown => params);
+
+jest.mock("src/services/fetch/fetchers/searchFetcher", () => ({
+ downloadOpportunities: (params: unknown) => mockDownloadOpportunities(params),
+}));
+
+jest.mock("next/server", () => ({
+ NextResponse: jest.fn((params: unknown): unknown => ({
+ calledWith: params,
+ })),
+}));
+
+// note that all calls to the GET endpoint need to be caught here since the behavior of the Next redirect
+// is to throw an error
+describe("search export GET request", () => {
+ afterEach(() => jest.clearAllMocks());
+ it("calls downloadOpportunities with expected arguments", async () => {
+ await GET(fakeRequestForSearchParams("status=closed&agency=EPA"));
+ expect(mockDownloadOpportunities).toHaveBeenCalledWith(fakeConvertedParams);
+ });
+
+ it("returns a new response created from the returned value of downloadOpportunties", async () => {
+ const response = await GET(
+ fakeRequestForSearchParams("status=closed&agency=EPA"),
+ );
+ expect(response).toEqual({
+ calledWith: fakeConvertedParams,
+ });
+ });
+});
diff --git a/frontend/tests/api/auth/session/route.test.ts b/frontend/tests/api/auth/session/route.test.ts
index 1db694619..32d55a506 100644
--- a/frontend/tests/api/auth/session/route.test.ts
+++ b/frontend/tests/api/auth/session/route.test.ts
@@ -19,7 +19,7 @@ jest.mock("next/server", () => ({
// note that all calls to the GET endpoint need to be caught here since the behavior of the Next redirect
// is to throw an error
-describe("GET request", () => {
+describe("session GET request", () => {
afterEach(() => jest.clearAllMocks());
it("returns the current session token when one exists", async () => {
getSessionMock.mockImplementation(() => ({
diff --git a/frontend/tests/components/search/ExportSearchResultsButton.test.tsx b/frontend/tests/components/search/ExportSearchResultsButton.test.tsx
new file mode 100644
index 000000000..a67be7e41
--- /dev/null
+++ b/frontend/tests/components/search/ExportSearchResultsButton.test.tsx
@@ -0,0 +1,39 @@
+import { axe } from "jest-axe";
+import { render, screen } from "tests/react-utils";
+
+import { ExportSearchResultsButton } from "src/components/search/ExportSearchResultsButton";
+
+const mockDownloadSearchResultsCSV = jest.fn();
+
+const fakeSearchParams = new URLSearchParams();
+
+jest.mock(
+ "src/services/fetch/fetchers/clientSearchResultsDownloadFetcher",
+ () => ({
+ downloadSearchResultsCSV: (params: unknown): unknown =>
+ Promise.resolve(mockDownloadSearchResultsCSV(params)),
+ }),
+);
+
+jest.mock("next/navigation", () => ({
+ useSearchParams: () => fakeSearchParams,
+}));
+
+describe("ExportSearchResultsButton", () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+ it("should not have basic accessibility issues", async () => {
+ const { container } = render();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it("calls downloadSearchResultsCSV with correct args on button click", () => {
+ render();
+ const button = screen.getByRole("button");
+ button.click();
+
+ expect(mockDownloadSearchResultsCSV).toHaveBeenCalledWith(fakeSearchParams);
+ });
+});
diff --git a/frontend/tests/e2e/search/search-download.spec.ts b/frontend/tests/e2e/search/search-download.spec.ts
new file mode 100644
index 000000000..d35671e4b
--- /dev/null
+++ b/frontend/tests/e2e/search/search-download.spec.ts
@@ -0,0 +1,19 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("Search results export", () => {
+ test("should download a csv file when requested", async ({ page }, {
+ project,
+ }) => {
+ // downloads work manually in safari, but can't get the test to work
+ if (project.name.match(/webkit/)) {
+ return;
+ }
+ const downloadPromise = page.waitForEvent("download");
+ await page.goto("/search");
+ await page
+ .locator('div[data-testid="search-download-button-container"] > button')
+ .click();
+ const download = await downloadPromise;
+ expect(download.url()).toBeTruthy();
+ });
+});
diff --git a/frontend/tests/e2e/search/search-loading.spec.ts b/frontend/tests/e2e/search/search-loading.spec.ts
index 53c02c829..f4bf2db84 100644
--- a/frontend/tests/e2e/search/search-loading.spec.ts
+++ b/frontend/tests/e2e/search/search-loading.spec.ts
@@ -1,4 +1,4 @@
-import skip, { expect, test } from "@playwright/test";
+import { expect, test } from "@playwright/test";
import { chromium } from "playwright-core";
import {
fillSearchInputAndSubmit,
@@ -7,7 +7,7 @@ import {
test.describe("Search page tests", () => {
// Loadiing indicator resolves too quickly to reliably test in e2e.
- skip("should show and hide loading state", async () => {
+ test.skip("should show and hide loading state", async () => {
const searchTerm = generateRandomString([4, 5]);
const searchTerm2 = generateRandomString([8]);
diff --git a/frontend/tests/e2e/search/search.spec.ts b/frontend/tests/e2e/search/search.spec.ts
index d472b124f..d99e7abad 100644
--- a/frontend/tests/e2e/search/search.spec.ts
+++ b/frontend/tests/e2e/search/search.spec.ts
@@ -54,10 +54,10 @@ test.describe("Search page tests", () => {
"category-agriculture": "agriculture",
};
- await selectSortBy(page, "agencyDesc");
-
await waitForSearchResultsInitialLoad(page);
+ await selectSortBy(page, "agencyDesc");
+
if (project.name.match(/[Mm]obile/)) {
await toggleMobileSearchFilters(page);
}
@@ -156,12 +156,12 @@ test.describe("Search page tests", () => {
page,
}: PageProps) => {
await page.goto("/search");
+ await waitForSearchResultsInitialLoad(page);
+
await selectSortBy(page, "opportunityTitleDesc");
await clickLastPaginationPage(page);
- await waitForSearchResultsInitialLoad(page);
-
const lastSearchResultTitle = await getLastSearchResultTitle(page);
await selectSortBy(page, "opportunityTitleAsc");
diff --git a/frontend/tests/services/fetch/FetcherHelpers.test.ts b/frontend/tests/services/fetch/FetcherHelpers.test.ts
index 12d69bd23..36b6f0f01 100644
--- a/frontend/tests/services/fetch/FetcherHelpers.test.ts
+++ b/frontend/tests/services/fetch/FetcherHelpers.test.ts
@@ -1,35 +1,12 @@
import "server-only";
-import { ApiRequestError, NetworkError, UnauthorizedError } from "src/errors";
+import { UnauthorizedError } from "src/errors";
import {
createRequestUrl,
- sendRequest,
throwError,
} from "src/services/fetch/fetcherHelpers";
-import { QueryParamData } from "src/types/search/searchRequestTypes";
import { wrapForExpectedError } from "src/utils/testing/commonTestUtils";
-const searchInputs: QueryParamData = {
- status: new Set(["active"]),
- fundingInstrument: new Set(["grant"]),
- eligibility: new Set(["public"]),
- agency: new Set(["NASA"]),
- category: new Set(["science"]),
- query: "space exploration",
- sortby: "relevancy",
- page: 1,
-};
-
-const responseJsonMock = jest
- .fn()
- .mockResolvedValue({ data: [], errors: [], warnings: [] });
-
-const fetchMock = jest.fn().mockResolvedValue({
- json: responseJsonMock,
- ok: true,
- status: 200,
-});
-
describe("createRequestUrl", () => {
it("creates the correct url without search params", () => {
const method = "GET";
@@ -75,105 +52,23 @@ describe("createRequestUrl", () => {
});
});
-describe("sendRequest", () => {
- let originalFetch: typeof global.fetch;
- beforeAll(() => {
- originalFetch = global.fetch;
- });
- afterAll(() => {
- global.fetch = originalFetch;
- });
- beforeEach(() => {
- global.fetch = fetchMock;
- });
- it("returns expected response body and calls fetch with expected arguments on successful request", async () => {
- const response = await sendRequest("any-url", {
- body: JSON.stringify({ key: "value" }),
- headers: {
- "Content-Type": "application/json",
- "Header-Name": "headerValue",
- },
- method: "POST",
- });
- expect(fetchMock).toHaveBeenCalledWith("any-url", {
- body: JSON.stringify({ key: "value" }),
- headers: {
- "Content-Type": "application/json",
- "Header-Name": "headerValue",
- },
- method: "POST",
- });
- expect(response).toEqual({ data: [], errors: [], warnings: [] });
- });
-
- it("handles `not ok` errors as expected", async () => {
- const errorMock = jest.fn().mockResolvedValue({
- json: responseJsonMock,
- ok: false,
- status: 200,
- });
- global.fetch = errorMock;
-
- const sendErrorRequest = async () => {
- await sendRequest(
- "any-url",
- {
- body: JSON.stringify({ key: "value" }),
- headers: {
- "Content-Type": "application/json",
- "Header-Name": "headerValue",
- },
- method: "POST",
- },
- searchInputs,
- );
- };
-
- await expect(sendErrorRequest()).rejects.toThrow(
- new ApiRequestError("", "APIRequestError", 0, { searchInputs }),
- );
- });
-
- it("handles network errors as expected", async () => {
- const networkError = new Error("o no an error");
- const errorMock = jest.fn(() => {
- throw networkError;
- });
- global.fetch = errorMock;
-
- const sendErrorRequest = async () => {
- await sendRequest(
- "any-url",
- {
- body: JSON.stringify({ key: "value" }),
- headers: {
- "Content-Type": "application/json",
- "Header-Name": "headerValue",
- },
- method: "POST",
- },
- searchInputs,
- );
- };
-
- await expect(sendErrorRequest()).rejects.toThrow(
- new NetworkError(networkError, searchInputs),
- );
- });
-});
-
describe("throwError", () => {
it("passes along message from response and details from first error, in error type based on status code", async () => {
const expectedError = await wrapForExpectedError(() => {
throwError(
- { data: {}, message: "response message", status_code: 401 },
- "http://any.url",
- undefined,
{
- field: "fieldName",
- type: "a subtype",
- message: "a detailed message",
+ data: {},
+ message: "response message",
+ status_code: 401,
+ errors: [
+ {
+ field: "fieldName",
+ type: "a subtype",
+ message: "a detailed message",
+ },
+ ],
},
+ "http://any.url",
);
});
expect(expectedError).toBeInstanceOf(UnauthorizedError);
diff --git a/frontend/tests/services/fetch/Fetchers.test.ts b/frontend/tests/services/fetch/Fetchers.test.ts
index e651d6b58..1241f1828 100644
--- a/frontend/tests/services/fetch/Fetchers.test.ts
+++ b/frontend/tests/services/fetch/Fetchers.test.ts
@@ -9,11 +9,26 @@ const createRequestUrlMock = jest.fn(
return "fakeurl";
},
);
-const sendRequestMock = jest.fn((..._args) => Promise.resolve("done"));
+const fakeJsonBody = {
+ data: [],
+ errors: [],
+ warnings: [],
+};
+
+const responseJsonMock = jest.fn().mockResolvedValue(fakeJsonBody);
+
+const fakeResponse = {
+ json: responseJsonMock,
+ ok: true,
+};
+
+const fetchMock = jest.fn().mockResolvedValue(fakeResponse);
+
const createRequestBodyMock = jest.fn((obj) => JSON.stringify(obj));
const getDefaultHeadersMock = jest.fn(() => ({
"Content-Type": "application/json",
}));
+const throwErrorMock = jest.fn();
jest.mock("src/services/fetch/fetcherHelpers", () => ({
createRequestUrl: (
@@ -32,9 +47,9 @@ jest.mock("src/services/fetch/fetcherHelpers", () => ({
subPath,
_body,
),
- sendRequest: (...args: unknown[]) => sendRequestMock(...args),
createRequestBody: (arg: unknown) => createRequestBodyMock(arg),
getDefaultHeaders: () => getDefaultHeadersMock(),
+ throwError: (...args: unknown[]): unknown => throwErrorMock(...args),
}));
jest.mock("react", () => ({
@@ -50,23 +65,25 @@ describe("requesterForEndpoint", () => {
method: "POST",
};
+ let originalFetch: typeof global.fetch;
+ beforeAll(() => {
+ originalFetch = global.fetch;
+ });
+ afterAll(() => {
+ global.fetch = originalFetch;
+ });
+ beforeEach(() => {
+ global.fetch = fetchMock;
+ });
+
it("returns a function", () => {
expect(typeof requesterForEndpoint(basicEndpoint)).toBe("function");
});
- it("returns a function that calls `createRequestUrl` andf `sendRequest` with the expected arguments", async () => {
+ it("returns a function that calls `createRequestUrl` and `fetch` with the expected arguments", async () => {
const requester = requesterForEndpoint(basicEndpoint);
await requester({
subPath: "1",
- queryParamData: {
- page: 1,
- status: new Set(),
- fundingInstrument: new Set(),
- eligibility: new Set(),
- agency: new Set(),
- category: new Set(),
- sortby: null,
- },
body: { key: "value" },
additionalHeaders: { "Header-Name": "headerValue" },
});
@@ -78,25 +95,40 @@ describe("requesterForEndpoint", () => {
"1",
{ key: "value" },
);
- expect(sendRequestMock).toHaveBeenCalledWith(
- "fakeurl/1",
- {
- body: JSON.stringify({ key: "value" }),
- headers: {
- "Content-Type": "application/json",
- "Header-Name": "headerValue",
- },
- method: "POST",
+ expect(fetchMock).toHaveBeenCalledWith("fakeurl/1", {
+ body: JSON.stringify({ key: "value" }),
+ headers: {
+ "Content-Type": "application/json",
+ "Header-Name": "headerValue",
},
- {
- page: 1,
- status: new Set(),
- fundingInstrument: new Set(),
- eligibility: new Set(),
- agency: new Set(),
- category: new Set(),
- sortby: null,
- },
- );
+ method: "POST",
+ });
+ });
+ it("returns a function that returns a fetch response", async () => {
+ const requester = requesterForEndpoint(basicEndpoint);
+ const response = await requester({
+ subPath: "1",
+ body: { key: "value" },
+ additionalHeaders: { "Header-Name": "headerValue" },
+ });
+ expect(response).toEqual(fakeResponse);
+ });
+ it("extracts errors from json response where applicable", async () => {
+ fetchMock.mockResolvedValue({
+ json: responseJsonMock,
+ ok: false,
+ status: 404,
+ headers: { get: () => "application/json" },
+ });
+
+ const requester = requesterForEndpoint(basicEndpoint);
+
+ await requester({
+ subPath: "1",
+ body: { key: "value" },
+ additionalHeaders: { "Header-Name": "headerValue" },
+ });
+ expect(responseJsonMock).toHaveBeenCalledTimes(1);
+ expect(throwErrorMock).toHaveBeenCalledWith(fakeJsonBody, "fakeurl/1");
});
});
diff --git a/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts b/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts
index 6c34fec13..dca102a56 100644
--- a/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts
+++ b/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts
@@ -1,6 +1,7 @@
import {
buildFilters,
buildPagination,
+ downloadOpportunities,
searchForOpportunities,
} from "src/services/fetch/fetchers/searchFetcher";
import {
@@ -22,7 +23,12 @@ const searchProps: QueryParamData = {
fieldChanged: "baseball",
};
-const mockfetchOpportunitySearch = jest.fn().mockResolvedValue({ data: {} });
+const mockfetchOpportunitySearch = jest.fn().mockResolvedValue({
+ json: () => ({
+ data: {},
+ }),
+ body: { data: {} },
+});
jest.mock("react", () => ({
...jest.requireActual("react"),
@@ -36,7 +42,10 @@ jest.mock("src/services/fetch/fetchers/fetchers", () => ({
}));
describe("searchForOpportunities", () => {
- it("calls request function with correct parameters", async () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+ it("calls request function with correct parameters and returns json data from response", async () => {
const result = await searchForOpportunities(searchProps);
expect(mockfetchOpportunitySearch).toHaveBeenCalledWith({
@@ -57,18 +66,6 @@ describe("searchForOpportunities", () => {
},
},
},
- queryParamData: {
- actionType: "fun",
- agency: new Set(),
- category: new Set(),
- eligibility: new Set(),
- fieldChanged: "baseball",
- fundingInstrument: new Set(["grant", "cooperative_agreement"]),
- page: 1,
- query: "research",
- sortby: "opportunityNumberAsc",
- status: new Set(["forecasted", "posted"]),
- },
});
expect(result).toEqual({
@@ -79,6 +76,40 @@ describe("searchForOpportunities", () => {
});
});
+describe("downloadOpportunities", () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+ it("calls request function with correct parameters and returns response", async () => {
+ const result = await downloadOpportunities(searchProps);
+
+ expect(mockfetchOpportunitySearch).toHaveBeenCalledWith({
+ body: {
+ pagination: {
+ order_by: "opportunity_number", // This should be the actual value being used in the API method
+ page_offset: 1,
+ page_size: 5000,
+ sort_direction: "ascending", // or "descending" based on your sortby parameter
+ },
+ query: "research",
+ filters: {
+ opportunity_status: {
+ one_of: ["forecasted", "posted"],
+ },
+ funding_instrument: {
+ one_of: ["grant", "cooperative_agreement"],
+ },
+ },
+ format: "csv",
+ },
+ });
+
+ expect(result).toEqual({
+ data: {},
+ });
+ });
+});
+
describe("buildFilters", () => {
it("maps all params to the correct filter names", () => {
const filters = buildFilters({
diff --git a/frontend/tests/services/fetch/fetchers/clientSearchResultsDownloadFetcher.test.ts b/frontend/tests/services/fetch/fetchers/clientSearchResultsDownloadFetcher.test.ts
new file mode 100644
index 000000000..38e763a98
--- /dev/null
+++ b/frontend/tests/services/fetch/fetchers/clientSearchResultsDownloadFetcher.test.ts
@@ -0,0 +1,76 @@
+/* eslint-disable @typescript-eslint/unbound-method */
+
+import { downloadSearchResultsCSV } from "src/services/fetch/fetchers/clientSearchResultsDownloadFetcher";
+import { getConfiguredDayJs } from "src/utils/dateUtil";
+
+import { ReadonlyURLSearchParams } from "next/navigation";
+
+const fakeBlob = new Blob();
+
+const getFakeFile = () =>
+ new File(
+ [fakeBlob],
+ `grants-search-${getConfiguredDayJs()(new Date()).format("YYYYMMDDHHmm")}.csv`,
+ {
+ type: "data:text/csv",
+ },
+ );
+
+const mockBlob = jest.fn(() => fakeBlob);
+
+const mockFetch = jest.fn(() =>
+ Promise.resolve({
+ blob: mockBlob,
+ ok: true,
+ }),
+);
+const mockCreateObjectUrl = jest.fn(() => "an object url");
+const mockLocationAssign = jest.fn();
+
+describe("downloadSearchResultsCSV", () => {
+ let originalFetch: typeof global.fetch;
+ let originalCreateObjectURL: typeof global.URL.createObjectURL;
+ let originalLocationAssign: typeof global.location.assign;
+
+ beforeEach(() => {
+ originalCreateObjectURL = global.URL.createObjectURL;
+ originalLocationAssign = global.location.assign;
+ Object.defineProperty(global, "location", {
+ value: { assign: mockLocationAssign },
+ });
+ originalFetch = global.fetch;
+ global.fetch = mockFetch as jest.Mock;
+ global.URL.createObjectURL = mockCreateObjectUrl;
+ });
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ global.location.assign = originalLocationAssign;
+ global.URL.createObjectURL = originalCreateObjectURL;
+ jest.clearAllMocks();
+ });
+
+ it("calls fetch with correct url", async () => {
+ await downloadSearchResultsCSV(
+ new ReadonlyURLSearchParams("status=fake&agency=alsoFake"),
+ );
+ expect(mockFetch).toHaveBeenCalledWith(
+ "/api/search/export?status=fake&agency=alsoFake",
+ );
+ });
+
+ it("blobs the response", async () => {
+ await downloadSearchResultsCSV(
+ new ReadonlyURLSearchParams("status=fake&agency=alsoFake"),
+ );
+ expect(mockBlob).toHaveBeenCalledTimes(1);
+ });
+
+ it("sets location with blob result", async () => {
+ await downloadSearchResultsCSV(
+ new ReadonlyURLSearchParams("status=fake&agency=alsoFake"),
+ );
+ expect(mockCreateObjectUrl).toHaveBeenCalledWith(getFakeFile());
+ expect(mockLocationAssign).toHaveBeenCalledWith("an object url");
+ });
+});
diff --git a/frontend/tests/services/fetch/fetchers/opportunityFetcher.test.ts b/frontend/tests/services/fetch/fetchers/opportunityFetcher.test.ts
new file mode 100644
index 000000000..c1cb082ff
--- /dev/null
+++ b/frontend/tests/services/fetch/fetchers/opportunityFetcher.test.ts
@@ -0,0 +1,28 @@
+import { getOpportunityDetails } from "src/services/fetch/fetchers/opportunityFetcher";
+
+const fakeResponseBody = { some: "response body" };
+const mockJson = jest.fn(() => fakeResponseBody);
+
+const mockfetchOpportunity = jest.fn().mockResolvedValue({
+ json: mockJson,
+});
+
+jest.mock("src/services/fetch/fetchers/fetchers", () => ({
+ fetchOpportunity: (params: unknown): unknown => {
+ return mockfetchOpportunity(params);
+ },
+}));
+
+describe("getOpportunityDetails", () => {
+ afterEach(() => jest.clearAllMocks());
+ it("calls fetchOpportunity with the correct arguments", async () => {
+ await getOpportunityDetails("an id");
+ expect(mockfetchOpportunity).toHaveBeenCalledWith({ subPath: "an id" });
+ });
+
+ it("returns json from response", async () => {
+ const result = await getOpportunityDetails("an id");
+ expect(mockJson).toHaveBeenCalledTimes(1);
+ expect(result).toEqual(fakeResponseBody);
+ });
+});