From 1b7db48c5487aa75b86371a9310816987aff8eea Mon Sep 17 00:00:00 2001 From: Neel Mokaria Date: Fri, 21 Nov 2025 17:02:59 -0500 Subject: [PATCH 1/2] Add nonprofit sorting functionality --- .DS_Store | Bin 0 -> 6148 bytes .env.example | 23 - .env.test | 17 +- .gitignore | 3 + package-lock.json | 23 +- .../graphql/resolvers/nonprofits.resolvers.ts | 26 +- src/api/graphql/schemas/nonprofits.schema.ts | 20 +- src/config/server.ts | 15 +- src/core/index.ts | 8 + src/core/services/nonprofits.service.ts | 349 +++++++++- .../graphql/nonprofits-sorting.test.ts | 271 ++++++++ tests/setup.ts | 30 +- .../unit/services/nonprofits.service.test.ts | 612 +++++++++++++++++- 13 files changed, 1324 insertions(+), 73 deletions(-) create mode 100644 .DS_Store delete mode 100644 .env.example create mode 100644 tests/integration/graphql/nonprofits-sorting.test.ts diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..81229e8d26382bd3469cb3d9bb2de3f1954844ac GIT binary patch literal 6148 zcmeH~Jqp4=5QS&dB4Cr!avKle4VIuM@B*S@B?yZB9^E%TjnP_yyn&f-XEsBUS7b9H zqQmpN5$Q#wgBxXSVPuMYE)TiO>2iLYjtb*@FJ*K=2U&T%hcRwa*e@u>x3=Er<$CqZN!+^)bZi z-VT<$t|nVB+C_8t(7dzS6a&*}7cEF&S{)2jfC`Khm`C2*`M-mIoBu~GOsN1B_%j7` zvE6S6yi}g8AFpTiLso6w;GkcQ@b(jc#E#+>+ztE17GO=bASy8a2)GOkRN$uyya2`( B5q1Co literal 0 HcmV?d00001 diff --git a/.env.example b/.env.example deleted file mode 100644 index 91c5a3e..0000000 --- a/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -# Database Configuration (connects via connection pooling) -SUPABASE_URL=your_supabase_connection_string_here -DATABASE_URL=your_database_url_here -DIRECT_URL=your_direct_url_here - -# Database Connection Pool Settings (optional - Prisma defaults are good) -DB_CONNECTION_LIMIT=10 -DB_POOL_TIMEOUT=30000 - -# Server Configuration -PORT=3001 -REST_PORT=3002 -GRAPHQL_PORT=3003 - -# Application Configuration -NODE_ENV=development -SEED_DATA=false - -# Security Configuration -# CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.com,https://staging.yourdomain.com - -# Optional: Add any API keys or other configuration -# JWT_SECRET=your_jwt_secret_here diff --git a/.env.test b/.env.test index 8cf5688..92fdfcf 100644 --- a/.env.test +++ b/.env.test @@ -1,12 +1,11 @@ -# Server Configuration (using different ports for testing) -PORT=4001 -REST_PORT=4002 -GRAPHQL_PORT=4003 +# Test Database Configuration +DATABASE_URL=postgresql://postgres:test_password@localhost:5433/test_db?schema=public +DIRECT_URL=postgresql://postgres:test_password@localhost:5433/test_db?schema=public -# Application Configuration +# Environment NODE_ENV=test -SEED_DATA=false -# Optional: Add any API keys or other configuration -# JWT_SECRET=your_jwt_secret_here -# CORS_ORIGIN=http://localhost:3000 +# Server Configuration +PORT=3001 +REST_PORT=3002 +GRAPHQL_PORT=3003 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3a28e26..bd23559 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ logs/ # Coverage files coverage/ +REVIEW_SUMMARY.md +NONPROFIT_SORTING_IMPLEMENTATION.md +.gitignore diff --git a/package-lock.json b/package-lock.json index a1ef52e..b0871df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "version": "7.26.10", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -625,7 +626,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -1645,6 +1648,7 @@ "version": "22.15.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1822,6 +1826,7 @@ "version": "8.46.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -2050,6 +2055,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2331,6 +2337,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -3156,6 +3163,7 @@ "version": "9.37.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3430,6 +3438,7 @@ "node_modules/express": { "version": "5.1.0", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -4413,13 +4422,13 @@ }, "node_modules/iterall": { "version": "1.3.0", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/jest": { "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -4968,7 +4977,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.1", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -5784,6 +5795,7 @@ "node_modules/pg": { "version": "8.15.6", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.8.5", "pg-pool": "^3.9.6", @@ -6005,6 +6017,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.17.1", "@prisma/engines": "6.17.1" @@ -6871,6 +6884,7 @@ "version": "10.9.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -6959,6 +6973,7 @@ "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/api/graphql/resolvers/nonprofits.resolvers.ts b/src/api/graphql/resolvers/nonprofits.resolvers.ts index f410d66..f38ed8a 100644 --- a/src/api/graphql/resolvers/nonprofits.resolvers.ts +++ b/src/api/graphql/resolvers/nonprofits.resolvers.ts @@ -2,21 +2,36 @@ import { createNonprofit, createNonprofitSchema, deleteNonprofit, - getAllNonprofits, getNonprofitById, + getNonprofitsWithFilters, updateNonprofit, updateNonprofitSchema, } from '../../../core'; import { GraphQLError } from 'graphql'; import { z } from 'zod'; +import type { + StatusType, + NonprofitSortOption, +} from '../../../core/services/nonprofits.service'; // Infer TypeScript types directly from your Zod schemas type CreateNonprofitInput = z.infer; type UpdateNonprofitInput = z.infer; +interface NonprofitsQueryArgs { + chapterIds?: string[]; + statuses?: StatusType[]; + sort?: NonprofitSortOption[]; +} + export const nonprofitResolvers = { Query: { - nonprofits: () => getAllNonprofits(), + nonprofits: ( + _parent: unknown, + { chapterIds, statuses, sort }: NonprofitsQueryArgs + ) => { + return getNonprofitsWithFilters({ chapterIds, statuses, sort }); + }, nonprofit: async (_parent: unknown, { id }: { id: string }) => { const nonprofit = await getNonprofitById(id); if (!nonprofit) { @@ -43,10 +58,13 @@ export const nonprofitResolvers = { }, updateNonprofit: ( _parent: unknown, - { id, input }: { id: string; input: UpdateNonprofitInput } + { + nonprofit_id, + input, + }: { nonprofit_id: string; input: UpdateNonprofitInput } ) => { const validatedInput = updateNonprofitSchema.parse(input); - return updateNonprofit(id, validatedInput); + return updateNonprofit(nonprofit_id, validatedInput); }, deleteNonprofit: (_parent: unknown, { id }: { id: string }) => { return deleteNonprofit(id); diff --git a/src/api/graphql/schemas/nonprofits.schema.ts b/src/api/graphql/schemas/nonprofits.schema.ts index 0b0eaaf..d2b47ac 100644 --- a/src/api/graphql/schemas/nonprofits.schema.ts +++ b/src/api/graphql/schemas/nonprofits.schema.ts @@ -1,4 +1,17 @@ export const nonprofitSchemaString = ` + enum NonprofitSortOption { + A_TO_Z + Z_TO_A + MOST_RECENT + STATUS + } + + # Reuse StatusType from chapters schema + enum StatusType { + ACTIVE + INACTIVE + } + type Nonprofit { nonprofit_id: ID! name: String! @@ -8,10 +21,15 @@ export const nonprofitSchemaString = ` contact_id: String! created_at: String! updated_at: String! + status: StatusType! } type Query { - nonprofits: [Nonprofit!]! + nonprofits( + chapterIds: [ID!] + statuses: [StatusType!] + sort: [NonprofitSortOption!] + ): [Nonprofit!]! nonprofit(id: ID!): Nonprofit } diff --git a/src/config/server.ts b/src/config/server.ts index 78cb8ad..46ed6f2 100644 --- a/src/config/server.ts +++ b/src/config/server.ts @@ -2,10 +2,19 @@ import dotenv from 'dotenv'; import { z } from 'zod'; +const envFilePath = (() => { + switch (process.env.NODE_ENV) { + case 'production': + return '.env'; + case 'test': + return '.env.test'; + default: + return '.env.local'; + } +})(); + dotenv.config({ - // path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env', - // TODO: fix once we get our own test database set up that's not our prod database - path: '.env', + path: envFilePath, }); // Define a schema for your environment variables diff --git a/src/core/index.ts b/src/core/index.ts index e214d23..6a81464 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -9,6 +9,14 @@ export * from './services/roles.service'; export * from './services/sponsors.service'; export * from './services/volunteer.service'; +// Export types for GraphQL resolvers +export type { + StatusType, + NonprofitSortOption, + GetNonprofitsFilters, + EnrichedNonprofit, +} from './services/nonprofits.service'; + // Validators export * from './validators/chapters.validator'; export * from './validators/companies.validator'; diff --git a/src/core/services/nonprofits.service.ts b/src/core/services/nonprofits.service.ts index a36b7f7..0b65630 100644 --- a/src/core/services/nonprofits.service.ts +++ b/src/core/services/nonprofits.service.ts @@ -12,6 +12,202 @@ import { Prisma } from '@prisma/client'; type CreateNonprofitInput = z.infer; type UpdateNonprofitInput = z.infer; +// Types for filtering and sorting +export type StatusType = 'ACTIVE' | 'INACTIVE'; +export type NonprofitSortOption = + | 'A_TO_Z' + | 'Z_TO_A' + | 'MOST_RECENT' + | 'STATUS'; + +export interface GetNonprofitsFilters { + chapterIds?: string[]; + statuses?: StatusType[]; + sort?: NonprofitSortOption[]; +} + +export interface EnrichedNonprofit { + nonprofit_id: string; + name: string; + mission: string; + website: string | null; + location_id: string | null; + contact_id: string; + created_at: Date; + updated_at: Date; + status: StatusType; + latestStartDate?: Date | null; +} + +/** + * Helper function to determine if a nonprofit is ACTIVE based on its projects + */ +function deriveNonprofitStatus( + projects: { + project_status: string; + end_date: Date | null; + }[] +): StatusType { + const now = new Date(); + const hasOngoingProject = projects.some((project) => { + return ( + project.project_status === 'ACTIVE' && + (project.end_date === null || new Date(project.end_date) > now) + ); + }); + return hasOngoingProject ? 'ACTIVE' : 'INACTIVE'; +} + +/** + * Helper function to get the latest start_date from projects + */ +function getLatestStartDate(projects: { start_date: Date }[]): Date | null { + if (projects.length === 0) return null; + return projects.reduce( + (latest, project) => { + const startDate = new Date(project.start_date); + return !latest || startDate > latest ? startDate : latest; + }, + null as Date | null + ); +} + +/** + * Comparator function for sorting nonprofits + */ +function createNonprofitComparator( + sortOptions: NonprofitSortOption[] +): (a: EnrichedNonprofit, b: EnrichedNonprofit) => number { + return (a, b) => { + for (const option of sortOptions) { + let comparison = 0; + + switch (option) { + case 'A_TO_Z': + comparison = a.name.localeCompare(b.name, undefined, { + sensitivity: 'base', + }); + break; + + case 'Z_TO_A': + comparison = b.name.localeCompare(a.name, undefined, { + sensitivity: 'base', + }); + break; + + case 'MOST_RECENT': { + // Sort by latest start date descending (newest first) + // Nonprofits without projects go last + const aDate = a.latestStartDate?.getTime() ?? -Infinity; + const bDate = b.latestStartDate?.getTime() ?? -Infinity; + comparison = bDate - aDate; + break; + } + + case 'STATUS': + // ACTIVE before INACTIVE + if (a.status === 'ACTIVE' && b.status === 'INACTIVE') { + comparison = -1; + } else if (a.status === 'INACTIVE' && b.status === 'ACTIVE') { + comparison = 1; + } + break; + } + + if (comparison !== 0) { + return comparison; + } + } + return 0; + }; +} + +/** + * Get nonprofits with optional filtering and sorting + */ +export async function getNonprofitsWithFilters( + filters: GetNonprofitsFilters = {} +): Promise { + try { + const { chapterIds, statuses, sort } = filters; + + logger.info('Fetching nonprofits with filters', { filters }); + + // Build Prisma where clause + const where: Prisma.nonprofitsWhereInput = {}; + + if (chapterIds && chapterIds.length > 0) { + where.nonprofit_chapter_project = { + some: { + chapter_id: { + in: chapterIds, + }, + }, + }; + } + + // Fetch nonprofits with related projects + const nonprofits = await prisma.nonprofits.findMany({ + where, + include: { + nonprofit_chapter_project: { + select: { + start_date: true, + end_date: true, + project_status: true, + chapter_id: true, + }, + }, + }, + orderBy: { created_at: 'desc' }, // Default ordering + }); + + // Enrich nonprofits with derived fields + const enrichedNonprofits: EnrichedNonprofit[] = nonprofits.map((np) => { + const status = deriveNonprofitStatus(np.nonprofit_chapter_project); + const latestStartDate = getLatestStartDate(np.nonprofit_chapter_project); + + return { + nonprofit_id: np.nonprofit_id, + name: np.name, + mission: np.mission, + website: np.website, + location_id: np.location_id, + contact_id: np.contact_id, + created_at: np.created_at, + updated_at: np.updated_at, + status, + latestStartDate, + }; + }); + + // Apply status filter + let filteredNonprofits = enrichedNonprofits; + if (statuses && statuses.length > 0) { + filteredNonprofits = enrichedNonprofits.filter((np) => + statuses.includes(np.status) + ); + } + + // Apply sorting + if (sort && sort.length > 0) { + const comparator = createNonprofitComparator(sort); + filteredNonprofits.sort(comparator); + } + + logger.info('Successfully retrieved and filtered nonprofits', { + count: filteredNonprofits.length, + }); + + return filteredNonprofits; + } catch (error) { + logger.error('Failed to fetch nonprofits with filters', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + throw new DatabaseError('Failed to retrieve nonprofits'); + } +} + export async function getAllNonprofits() { try { logger.info('Fetching all nonprofits'); @@ -32,12 +228,22 @@ export async function getAllNonprofits() { } } -export async function getNonprofitById(id: string) { +export async function getNonprofitById(id: string): Promise { try { logger.info('Fetching nonprofit by ID', { nonprofitId: id }); const nonprofit = await prisma.nonprofits.findUnique({ where: { nonprofit_id: id }, + include: { + nonprofit_chapter_project: { + select: { + start_date: true, + end_date: true, + project_status: true, + chapter_id: true, + }, + }, + }, }); if (!nonprofit) { @@ -45,12 +251,30 @@ export async function getNonprofitById(id: string) { throw new NotFoundError('Nonprofit', id); } + // Derive status and latest start date + const status = deriveNonprofitStatus(nonprofit.nonprofit_chapter_project); + const latestStartDate = getLatestStartDate( + nonprofit.nonprofit_chapter_project + ); + logger.info('Successfully retrieved nonprofit', { nonprofitId: id, nonprofitName: nonprofit.name, + status, }); - return nonprofit; + return { + nonprofit_id: nonprofit.nonprofit_id, + name: nonprofit.name, + mission: nonprofit.mission, + website: nonprofit.website, + location_id: nonprofit.location_id, + contact_id: nonprofit.contact_id, + created_at: nonprofit.created_at, + updated_at: nonprofit.updated_at, + status, + latestStartDate, + }; } catch (error) { if (error instanceof NotFoundError) { throw error; @@ -64,7 +288,9 @@ export async function getNonprofitById(id: string) { } } -export async function createNonprofit(input: CreateNonprofitInput) { +export async function createNonprofit( + input: CreateNonprofitInput +): Promise { try { logger.info('Creating new nonprofit', { nonprofitName: input.name, @@ -82,14 +308,44 @@ export async function createNonprofit(input: CreateNonprofitInput) { ...(location_id === undefined ? {} : { location_id }), // include if not undefined (can be null) }; - const nonprofit = await prisma.nonprofits.create({ data }); + const nonprofit = await prisma.nonprofits.create({ + data, + include: { + nonprofit_chapter_project: { + select: { + start_date: true, + end_date: true, + project_status: true, + chapter_id: true, + }, + }, + }, + }); + + // Derive status and latest start date + const status = deriveNonprofitStatus(nonprofit.nonprofit_chapter_project); + const latestStartDate = getLatestStartDate( + nonprofit.nonprofit_chapter_project + ); logger.info('Successfully created nonprofit', { nonprofitId: nonprofit.nonprofit_id, nonprofitName: nonprofit.name, + status, }); - return nonprofit; + return { + nonprofit_id: nonprofit.nonprofit_id, + name: nonprofit.name, + mission: nonprofit.mission, + website: nonprofit.website, + location_id: nonprofit.location_id, + contact_id: nonprofit.contact_id, + created_at: nonprofit.created_at, + updated_at: nonprofit.updated_at, + status, + latestStartDate, + }; } catch (error) { logger.error('Failed to create nonprofit', { nonprofitData: input, @@ -99,7 +355,10 @@ export async function createNonprofit(input: CreateNonprofitInput) { } } -export async function updateNonprofit(id: string, input: UpdateNonprofitInput) { +export async function updateNonprofit( + id: string, + input: UpdateNonprofitInput +): Promise { try { logger.info('Updating nonprofit', { nonprofitId: id, @@ -119,14 +378,42 @@ export async function updateNonprofit(id: string, input: UpdateNonprofitInput) { const nonprofit = await prisma.nonprofits.update({ where: { nonprofit_id: id }, data, + include: { + nonprofit_chapter_project: { + select: { + start_date: true, + end_date: true, + project_status: true, + chapter_id: true, + }, + }, + }, }); + // Derive status and latest start date + const status = deriveNonprofitStatus(nonprofit.nonprofit_chapter_project); + const latestStartDate = getLatestStartDate( + nonprofit.nonprofit_chapter_project + ); + logger.info('Successfully updated nonprofit', { nonprofitId: id, nonprofitName: nonprofit.name, + status, }); - return nonprofit; + return { + nonprofit_id: nonprofit.nonprofit_id, + name: nonprofit.name, + mission: nonprofit.mission, + website: nonprofit.website, + location_id: nonprofit.location_id, + contact_id: nonprofit.contact_id, + created_at: nonprofit.created_at, + updated_at: nonprofit.updated_at, + status, + latestStartDate, + }; } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && @@ -152,21 +439,63 @@ export async function updateNonprofit(id: string, input: UpdateNonprofitInput) { } } -export async function deleteNonprofit(id: string) { +export async function deleteNonprofit(id: string): Promise { try { logger.info('Deleting nonprofit', { nonprofitId: id }); - const nonprofit = await prisma.nonprofits.delete({ + // Fetch the nonprofit with projects before deleting to return enriched data + const nonprofit = await prisma.nonprofits.findUnique({ + where: { nonprofit_id: id }, + include: { + nonprofit_chapter_project: { + select: { + start_date: true, + end_date: true, + project_status: true, + chapter_id: true, + }, + }, + }, + }); + + if (!nonprofit) { + throw new NotFoundError('Nonprofit', id); + } + + // Derive status and latest start date before deleting + const status = deriveNonprofitStatus(nonprofit.nonprofit_chapter_project); + const latestStartDate = getLatestStartDate( + nonprofit.nonprofit_chapter_project + ); + + // Now delete the nonprofit + await prisma.nonprofits.delete({ where: { nonprofit_id: id }, }); logger.info('Successfully deleted nonprofit', { nonprofitId: id, nonprofitName: nonprofit.name, + status, }); - return nonprofit; + return { + nonprofit_id: nonprofit.nonprofit_id, + name: nonprofit.name, + mission: nonprofit.mission, + website: nonprofit.website, + location_id: nonprofit.location_id, + contact_id: nonprofit.contact_id, + created_at: nonprofit.created_at, + updated_at: nonprofit.updated_at, + status, + latestStartDate, + }; } catch (error) { + if (error instanceof NotFoundError) { + throw error; + } + if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025' diff --git a/tests/integration/graphql/nonprofits-sorting.test.ts b/tests/integration/graphql/nonprofits-sorting.test.ts new file mode 100644 index 0000000..a23f06d --- /dev/null +++ b/tests/integration/graphql/nonprofits-sorting.test.ts @@ -0,0 +1,271 @@ +import request from 'supertest'; +import express from 'express'; +import { Server } from 'http'; +import { createGraphqlApp } from '../../../src/api/graphql/server'; +import { env } from '../../../src/config/server'; +import { prisma } from '../../../src/config/database'; + +interface NonprofitResponse { + nonprofit_id: string; + name: string; + status: string; +} + +let server: Server; +let app: express.Application; +let contactId: string; +let chapterId: string; +let projectId: string; +let nonprofit1Id: string; +let nonprofit2Id: string; +let nonprofit3Id: string; + +beforeAll(async () => { + app = createGraphqlApp(); + server = app.listen(env.GRAPHQL_PORT); + + const contactMutation = `mutation CreateContact($input: CreateContactInput!) { createContact(input: $input) { contact_id } }`; + const contactResponse = await request(server) + .post('/') + .send({ + query: contactMutation, + variables: { + input: { + name: 'Sorting Test Contact', + email: 'sorting@test.com', + phone_number: '123-456-7890', + }, + }, + }); + contactId = contactResponse.body.data.createContact.contact_id; + + const chapterMutation = `mutation CreateChapter($input: CreateChapterInput!) { createChapter(input: $input) { chapter_id } }`; + const chapterResponse = await request(server) + .post('/') + .send({ + query: chapterMutation, + variables: { + input: { + name: 'Test Chapter for Sorting', + founded_date: '2020-01-01', + status_type: 'ACTIVE', + }, + }, + }); + chapterId = chapterResponse.body.data.createChapter.chapter_id; + + const projectMutation = `mutation CreateProject($input: CreateProjectInput!) { createProject(input: $input) { project_id } }`; + const projectResponse = await request(server) + .post('/') + .send({ + query: projectMutation, + variables: { + input: { + name: 'Test Project for Sorting', + repo: 'https://github.com/test/sorting', + project_type: 'DESIGN_AND_DEVELOP', + }, + }, + }); + projectId = projectResponse.body.data.createProject.project_id; + + const createNonprofitMutation = `mutation CreateNonprofit($input: CreateNonprofitInput!) { createNonprofit(input: $input) { nonprofit_id } }`; + const np1 = await request(server) + .post('/') + .send({ + query: createNonprofitMutation, + variables: { + input: { + name: 'Zebra Nonprofit', + mission: 'Test mission', + contact_id: contactId, + }, + }, + }); + nonprofit1Id = np1.body.data.createNonprofit.nonprofit_id; + const np2 = await request(server) + .post('/') + .send({ + query: createNonprofitMutation, + variables: { + input: { + name: 'Alpha Nonprofit', + mission: 'Test mission', + contact_id: contactId, + }, + }, + }); + nonprofit2Id = np2.body.data.createNonprofit.nonprofit_id; + const np3 = await request(server) + .post('/') + .send({ + query: createNonprofitMutation, + variables: { + input: { + name: 'Beta Nonprofit', + mission: 'Test mission', + contact_id: contactId, + }, + }, + }); + nonprofit3Id = np3.body.data.createNonprofit.nonprofit_id; + + await prisma.nonprofit_chapter_project.create({ + data: { + nonprofit_id: nonprofit1Id, + chapter_id: chapterId, + project_id: projectId, + project_contact_id: contactId, + collab_contact_id: contactId, + start_date: new Date('2024-06-01'), + end_date: new Date('2025-12-31'), + project_status: 'ACTIVE', + }, + }); + await prisma.nonprofit_chapter_project.create({ + data: { + nonprofit_id: nonprofit2Id, + chapter_id: chapterId, + project_id: projectId, + project_contact_id: contactId, + collab_contact_id: contactId, + start_date: new Date('2023-01-01'), + end_date: new Date('2023-12-31'), + project_status: 'INACTIVE', + }, + }); + await prisma.nonprofit_chapter_project.create({ + data: { + nonprofit_id: nonprofit3Id, + chapter_id: chapterId, + project_id: projectId, + project_contact_id: contactId, + collab_contact_id: contactId, + start_date: new Date('2024-01-01'), + end_date: null, + project_status: 'ACTIVE', + }, + }); +}); + +afterAll(async () => { + await prisma.nonprofit_chapter_project.deleteMany({ + where: { nonprofit_id: { in: [nonprofit1Id, nonprofit2Id, nonprofit3Id] } }, + }); + await prisma.nonprofits.deleteMany({ + where: { nonprofit_id: { in: [nonprofit1Id, nonprofit2Id, nonprofit3Id] } }, + }); + await prisma.projects.deleteMany({ where: { project_id: projectId } }); + await prisma.chapters.deleteMany({ where: { chapter_id: chapterId } }); + await prisma.contacts.deleteMany({ where: { contact_id: contactId } }); + server.close(); +}); + +describe('GraphQL API - Nonprofits Sorting and Filtering', () => { + const nonprofitsQuery = `query Nonprofits($chapterIds: [ID!], $statuses: [StatusType!], $sort: [NonprofitSortOption!]) { nonprofits(chapterIds: $chapterIds, statuses: $statuses, sort: $sort) { nonprofit_id name status } }`; + + it('should sort nonprofits A to Z by name', async () => { + const response = await request(server) + .post('/') + .send({ query: nonprofitsQuery, variables: { sort: ['A_TO_Z'] } }); + expect(response.status).toBe(200); + const testNPs = response.body.data.nonprofits.filter( + (np: NonprofitResponse) => + ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( + np.name + ) + ); + expect(testNPs[0].name).toBe('Alpha Nonprofit'); + expect(testNPs[1].name).toBe('Beta Nonprofit'); + expect(testNPs[2].name).toBe('Zebra Nonprofit'); + }); + + it('should sort nonprofits Z to A by name', async () => { + const response = await request(server) + .post('/') + .send({ query: nonprofitsQuery, variables: { sort: ['Z_TO_A'] } }); + const testNPs = response.body.data.nonprofits.filter( + (np: NonprofitResponse) => + ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( + np.name + ) + ); + expect(testNPs[0].name).toBe('Zebra Nonprofit'); + expect(testNPs[2].name).toBe('Alpha Nonprofit'); + }); + + it('should sort by most recent', async () => { + const response = await request(server) + .post('/') + .send({ query: nonprofitsQuery, variables: { sort: ['MOST_RECENT'] } }); + const testNPs = response.body.data.nonprofits.filter( + (np: NonprofitResponse) => + ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( + np.name + ) + ); + expect(testNPs[0].name).toBe('Zebra Nonprofit'); + }); + + it('should sort by status', async () => { + const response = await request(server) + .post('/') + .send({ query: nonprofitsQuery, variables: { sort: ['STATUS'] } }); + const testNPs = response.body.data.nonprofits.filter( + (np: NonprofitResponse) => + ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( + np.name + ) + ); + expect( + testNPs.filter((np: NonprofitResponse) => np.status === 'ACTIVE').length + ).toBe(2); + }); + + it('should filter by ACTIVE status', async () => { + const response = await request(server) + .post('/') + .send({ query: nonprofitsQuery, variables: { statuses: ['ACTIVE'] } }); + const testNPs = response.body.data.nonprofits.filter( + (np: NonprofitResponse) => + ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( + np.name + ) + ); + expect(testNPs.length).toBe(2); + expect( + testNPs.every((np: NonprofitResponse) => np.status === 'ACTIVE') + ).toBe(true); + }); + + it('should filter by chapter', async () => { + const response = await request(server) + .post('/') + .send({ query: nonprofitsQuery, variables: { chapterIds: [chapterId] } }); + expect(response.status).toBe(200); + const testNPs = response.body.data.nonprofits.filter( + (np: NonprofitResponse) => + ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( + np.name + ) + ); + expect(testNPs.length).toBe(3); + }); + + it('should combine STATUS and MOST_RECENT sorting', async () => { + const response = await request(server) + .post('/') + .send({ + query: nonprofitsQuery, + variables: { sort: ['STATUS', 'MOST_RECENT'] }, + }); + const testNPs = response.body.data.nonprofits.filter( + (np: NonprofitResponse) => + ['Alpha Nonprofit', 'Beta Nonprofit', 'Zebra Nonprofit'].includes( + np.name + ) + ); + expect(testNPs[0].status).toBe('ACTIVE'); + expect(testNPs[0].name).toBe('Zebra Nonprofit'); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 8035eff..fda06f0 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -2,20 +2,32 @@ import { prisma, connectDB, disconnectDB } from '../src/config/database'; // Global test setup - runs once before all tests beforeAll(async () => { - try { - await connectDB(); - } catch (error) { - console.error('Failed to connect to test database:', error); - process.exit(1); + // Only connect for integration tests, not unit tests + const testFilePath = expect.getState().testPath; + const isIntegrationTest = testFilePath?.includes('/integration/') ?? false; + + if (isIntegrationTest) { + try { + await connectDB(); + } catch (error) { + console.error('Failed to connect to test database:', error); + process.exit(1); + } } }); // Global test teardown - runs once after all tests afterAll(async () => { - try { - await disconnectDB(); - } catch (error) { - console.error('Failed to disconnect from test database:', error); + // Only disconnect for integration tests + const testFilePath = expect.getState().testPath; + const isIntegrationTest = testFilePath?.includes('/integration/') ?? false; + + if (isIntegrationTest) { + try { + await disconnectDB(); + } catch (error) { + console.error('Failed to disconnect from test database:', error); + } } }); diff --git a/tests/unit/services/nonprofits.service.test.ts b/tests/unit/services/nonprofits.service.test.ts index 273576d..1dcd694 100644 --- a/tests/unit/services/nonprofits.service.test.ts +++ b/tests/unit/services/nonprofits.service.test.ts @@ -4,6 +4,7 @@ import { createNonprofit, updateNonprofit, deleteNonprofit, + getNonprofitsWithFilters, } from '../../../src/core'; import { prisma } from '../../../src/config/database'; @@ -56,6 +57,8 @@ describe('Nonprofit Service', () => { describe('getNonprofitById', () => { it('should return a single nonprofit when a valid ID is provided', async () => { const nonprofitId = '1'; + const baseDate = new Date(); + const futureDate = new Date('2025-12-31'); const mockNonprofit = { nonprofit_id: '1', name: 'Test Nonprofit 1', @@ -63,8 +66,16 @@ describe('Nonprofit Service', () => { website: 'A test repo link', location_id: '0d0f34a1-3f6f-4f7f-8a9e-0e1a2b3c4d5e', contact_id: 'e9ded8b8-53e3-4747-aafa-d662458c2a65', - created_at: new Date(), - updated_at: new Date(), + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: baseDate, + end_date: futureDate, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], }; (prisma.nonprofits.findUnique as jest.Mock).mockResolvedValue( mockNonprofit @@ -72,9 +83,21 @@ describe('Nonprofit Service', () => { const result = await getNonprofitById(nonprofitId); - expect(result).toEqual(mockNonprofit); + expect(result.nonprofit_id).toBe(mockNonprofit.nonprofit_id); + expect(result.name).toBe(mockNonprofit.name); + expect(result.status).toBe('ACTIVE'); expect(prisma.nonprofits.findUnique).toHaveBeenCalledWith({ where: { nonprofit_id: nonprofitId }, + include: { + nonprofit_chapter_project: { + select: { + start_date: true, + end_date: true, + project_status: true, + chapter_id: true, + }, + }, + }, }); }); @@ -103,6 +126,7 @@ describe('Nonprofit Service', () => { ...inputData, created_at: new Date(), updated_at: new Date(), + nonprofit_chapter_project: [], // New nonprofit has no projects }; (prisma.nonprofits.create as jest.Mock).mockResolvedValue( @@ -111,7 +135,9 @@ describe('Nonprofit Service', () => { const result = await createNonprofit(inputData); - expect(result).toEqual(createdNonprofit); + expect(result.nonprofit_id).toBe(createdNonprofit.nonprofit_id); + expect(result.name).toBe(createdNonprofit.name); + expect(result.status).toBe('INACTIVE'); // New nonprofit with no projects is INACTIVE expect(prisma.nonprofits.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -121,6 +147,16 @@ describe('Nonprofit Service', () => { website: inputData.website, location_id: inputData.location_id, }), + include: { + nonprofit_chapter_project: { + select: { + start_date: true, + end_date: true, + project_status: true, + chapter_id: true, + }, + }, + }, }); }); }); @@ -129,6 +165,8 @@ describe('Nonprofit Service', () => { it('should update and return the nonprofit', async () => { const nonprofitId = '1'; const updateData = { name: 'Updated Nonprofit Name' }; + const baseDate = new Date(); + const futureDate = new Date('2025-12-31'); const updatedNonprofit = { nonprofit_id: '1', @@ -137,8 +175,16 @@ describe('Nonprofit Service', () => { website: 'A test repo link', location_id: '0d0f34a1-3f6f-4f7f-8a9e-0e1a2b3c4d5e', contact_id: 'e9ded8b8-53e3-4747-aafa-d662458c2a65', - created_at: new Date(), - updated_at: new Date(), + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: baseDate, + end_date: futureDate, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], }; (prisma.nonprofits.update as jest.Mock).mockResolvedValue( @@ -147,10 +193,22 @@ describe('Nonprofit Service', () => { const result = await updateNonprofit(nonprofitId, updateData); - expect(result).toEqual(updatedNonprofit); + expect(result.nonprofit_id).toBe(updatedNonprofit.nonprofit_id); + expect(result.name).toBe(updatedNonprofit.name); + expect(result.status).toBe('ACTIVE'); expect(prisma.nonprofits.update).toHaveBeenCalledWith({ where: { nonprofit_id: nonprofitId }, data: updateData, + include: { + nonprofit_chapter_project: { + select: { + start_date: true, + end_date: true, + project_status: true, + chapter_id: true, + }, + }, + }, }); }); }); @@ -158,6 +216,7 @@ describe('Nonprofit Service', () => { describe('deleteNonprofit', () => { it('should call delete with the correct ID and return the deleted nonprofit', async () => { const nonprofitId = '1'; + const baseDate = new Date(); const deletedNonprofit = { nonprofit_id: nonprofitId, name: 'Test Nonprofit 1', @@ -165,20 +224,553 @@ describe('Nonprofit Service', () => { website: 'A test repo link', location_id: '0d0f34a1-3f6f-4f7f-8a9e-0e1a2b3c4d5e', contact_id: 'e9ded8b8-53e3-4747-aafa-d662458c2a65', - created_at: new Date(), - updated_at: new Date(), + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [], // No projects }; + (prisma.nonprofits.findUnique as jest.Mock).mockResolvedValue( + deletedNonprofit + ); (prisma.nonprofits.delete as jest.Mock).mockResolvedValue( deletedNonprofit ); const result = await deleteNonprofit(nonprofitId); - expect(result).toEqual(deletedNonprofit); + expect(result.nonprofit_id).toBe(deletedNonprofit.nonprofit_id); + expect(result.name).toBe(deletedNonprofit.name); + expect(result.status).toBe('INACTIVE'); // No projects means INACTIVE + expect(prisma.nonprofits.findUnique).toHaveBeenCalledWith({ + where: { nonprofit_id: nonprofitId }, + include: { + nonprofit_chapter_project: { + select: { + start_date: true, + end_date: true, + project_status: true, + chapter_id: true, + }, + }, + }, + }); expect(prisma.nonprofits.delete).toHaveBeenCalledWith({ where: { nonprofit_id: nonprofitId }, }); }); }); + + describe('getNonprofitsWithFilters', () => { + const baseDate = new Date('2024-01-01'); + const futureDate = new Date('2025-12-31'); + const pastDate = new Date('2023-01-01'); + + it('should return all nonprofits with derived status when no filters are provided', async () => { + const mockNonprofits = [ + { + nonprofit_id: '1', + name: 'Active Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-1', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: baseDate, + end_date: futureDate, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + { + nonprofit_id: '2', + name: 'Inactive Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-2', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: pastDate, + end_date: pastDate, + project_status: 'INACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + ]; + + (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( + mockNonprofits + ); + + const result = await getNonprofitsWithFilters({}); + + expect(result).toHaveLength(2); + expect(result[0].status).toBe('ACTIVE'); + expect(result[1].status).toBe('INACTIVE'); + }); + + it('should filter nonprofits by chapter IDs', async () => { + const mockNonprofits = [ + { + nonprofit_id: '1', + name: 'Nonprofit 1', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-1', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: baseDate, + end_date: null, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + ]; + + (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( + mockNonprofits + ); + + await getNonprofitsWithFilters({ chapterIds: ['chapter-1'] }); + + expect(prisma.nonprofits.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + nonprofit_chapter_project: { + some: { + chapter_id: { + in: ['chapter-1'], + }, + }, + }, + }, + }) + ); + }); + + it('should filter nonprofits by ACTIVE status', async () => { + const mockNonprofits = [ + { + nonprofit_id: '1', + name: 'Active Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-1', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: baseDate, + end_date: futureDate, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + { + nonprofit_id: '2', + name: 'Inactive Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-2', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: pastDate, + end_date: pastDate, + project_status: 'INACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + ]; + + (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( + mockNonprofits + ); + + const result = await getNonprofitsWithFilters({ statuses: ['ACTIVE'] }); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('ACTIVE'); + expect(result[0].name).toBe('Active Nonprofit'); + }); + + it('should sort nonprofits A to Z by name', async () => { + const mockNonprofits = [ + { + nonprofit_id: '1', + name: 'Zebra Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-1', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [], + }, + { + nonprofit_id: '2', + name: 'Alpha Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-2', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [], + }, + { + nonprofit_id: '3', + name: 'Beta Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-3', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [], + }, + ]; + + (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( + mockNonprofits + ); + + const result = await getNonprofitsWithFilters({ sort: ['A_TO_Z'] }); + + expect(result[0].name).toBe('Alpha Nonprofit'); + expect(result[1].name).toBe('Beta Nonprofit'); + expect(result[2].name).toBe('Zebra Nonprofit'); + }); + + it('should sort nonprofits Z to A by name', async () => { + const mockNonprofits = [ + { + nonprofit_id: '1', + name: 'Alpha Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-1', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [], + }, + { + nonprofit_id: '2', + name: 'Zebra Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-2', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [], + }, + ]; + + (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( + mockNonprofits + ); + + const result = await getNonprofitsWithFilters({ sort: ['Z_TO_A'] }); + + expect(result[0].name).toBe('Zebra Nonprofit'); + expect(result[1].name).toBe('Alpha Nonprofit'); + }); + + it('should sort nonprofits by most recent project start date', async () => { + const oldDate = new Date('2023-01-01'); + const recentDate = new Date('2024-06-01'); + + const mockNonprofits = [ + { + nonprofit_id: '1', + name: 'Old Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-1', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: oldDate, + end_date: null, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + { + nonprofit_id: '2', + name: 'Recent Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-2', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: recentDate, + end_date: null, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + { + nonprofit_id: '3', + name: 'No Projects Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-3', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [], + }, + ]; + + (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( + mockNonprofits + ); + + const result = await getNonprofitsWithFilters({ sort: ['MOST_RECENT'] }); + + expect(result[0].name).toBe('Recent Nonprofit'); + expect(result[1].name).toBe('Old Nonprofit'); + expect(result[2].name).toBe('No Projects Nonprofit'); + }); + + it('should sort nonprofits by status (ACTIVE first)', async () => { + const mockNonprofits = [ + { + nonprofit_id: '1', + name: 'Inactive Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-1', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: pastDate, + end_date: pastDate, + project_status: 'INACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + { + nonprofit_id: '2', + name: 'Active Nonprofit', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-2', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: baseDate, + end_date: futureDate, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + ]; + + (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( + mockNonprofits + ); + + const result = await getNonprofitsWithFilters({ sort: ['STATUS'] }); + + expect(result[0].status).toBe('ACTIVE'); + expect(result[1].status).toBe('INACTIVE'); + }); + + it('should combine multiple sort options (STATUS then MOST_RECENT)', async () => { + const oldDate = new Date('2023-01-01'); + const recentDate = new Date('2024-06-01'); + + const mockNonprofits = [ + { + nonprofit_id: '1', + name: 'Old Active', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-1', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: oldDate, + end_date: futureDate, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + { + nonprofit_id: '2', + name: 'Recent Active', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-2', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: recentDate, + end_date: futureDate, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + { + nonprofit_id: '3', + name: 'Inactive', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-3', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: recentDate, + end_date: pastDate, + project_status: 'INACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + ]; + + (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( + mockNonprofits + ); + + const result = await getNonprofitsWithFilters({ + sort: ['STATUS', 'MOST_RECENT'], + }); + + // Both active nonprofits should come first, sorted by most recent + expect(result[0].name).toBe('Recent Active'); + expect(result[0].status).toBe('ACTIVE'); + expect(result[1].name).toBe('Old Active'); + expect(result[1].status).toBe('ACTIVE'); + expect(result[2].name).toBe('Inactive'); + expect(result[2].status).toBe('INACTIVE'); + }); + + it('should determine ACTIVE status when project has no end_date', async () => { + const mockNonprofits = [ + { + nonprofit_id: '1', + name: 'Ongoing Project', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-1', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: baseDate, + end_date: null, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + ]; + + (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( + mockNonprofits + ); + + const result = await getNonprofitsWithFilters({}); + + expect(result[0].status).toBe('ACTIVE'); + }); + + it('should determine INACTIVE status when all projects have ended', async () => { + const mockNonprofits = [ + { + nonprofit_id: '1', + name: 'Ended Projects', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-1', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [ + { + start_date: pastDate, + end_date: pastDate, + project_status: 'ACTIVE', + chapter_id: 'chapter-1', + }, + ], + }, + ]; + + (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( + mockNonprofits + ); + + const result = await getNonprofitsWithFilters({}); + + expect(result[0].status).toBe('INACTIVE'); + }); + + it('should determine INACTIVE status when nonprofit has no projects', async () => { + const mockNonprofits = [ + { + nonprofit_id: '1', + name: 'No Projects', + mission: 'Test mission', + website: null, + location_id: null, + contact_id: 'contact-1', + created_at: baseDate, + updated_at: baseDate, + nonprofit_chapter_project: [], + }, + ]; + + (prisma.nonprofits.findMany as jest.Mock).mockResolvedValue( + mockNonprofits + ); + + const result = await getNonprofitsWithFilters({}); + + expect(result[0].status).toBe('INACTIVE'); + }); + }); }); From 6f6935b5344e2bffe7637e3d2c9d5645b90a4887 Mon Sep 17 00:00:00 2001 From: Neel Mokaria Date: Sat, 22 Nov 2025 13:45:36 -0500 Subject: [PATCH 2/2] Remove duplicate StatusType enum and fix nonprofit sorting test setup - Removed duplicate StatusType enum definition from nonprofits schema - Moved chapter and project creation from beforeAll to beforeEach to handle global test cleanup - Added null checks in afterAll cleanup to prevent deletion errors - Fixed founded_date format --- src/api/graphql/schemas/nonprofits.schema.ts | 6 -- .../graphql/nonprofits-sorting.test.ts | 96 +++++++++++-------- 2 files changed, 57 insertions(+), 45 deletions(-) diff --git a/src/api/graphql/schemas/nonprofits.schema.ts b/src/api/graphql/schemas/nonprofits.schema.ts index d2b47ac..46e71eb 100644 --- a/src/api/graphql/schemas/nonprofits.schema.ts +++ b/src/api/graphql/schemas/nonprofits.schema.ts @@ -6,12 +6,6 @@ export const nonprofitSchemaString = ` STATUS } - # Reuse StatusType from chapters schema - enum StatusType { - ACTIVE - INACTIVE - } - type Nonprofit { nonprofit_id: ID! name: String! diff --git a/tests/integration/graphql/nonprofits-sorting.test.ts b/tests/integration/graphql/nonprofits-sorting.test.ts index a23f06d..b7c0557 100644 --- a/tests/integration/graphql/nonprofits-sorting.test.ts +++ b/tests/integration/graphql/nonprofits-sorting.test.ts @@ -39,36 +39,6 @@ beforeAll(async () => { }); contactId = contactResponse.body.data.createContact.contact_id; - const chapterMutation = `mutation CreateChapter($input: CreateChapterInput!) { createChapter(input: $input) { chapter_id } }`; - const chapterResponse = await request(server) - .post('/') - .send({ - query: chapterMutation, - variables: { - input: { - name: 'Test Chapter for Sorting', - founded_date: '2020-01-01', - status_type: 'ACTIVE', - }, - }, - }); - chapterId = chapterResponse.body.data.createChapter.chapter_id; - - const projectMutation = `mutation CreateProject($input: CreateProjectInput!) { createProject(input: $input) { project_id } }`; - const projectResponse = await request(server) - .post('/') - .send({ - query: projectMutation, - variables: { - input: { - name: 'Test Project for Sorting', - repo: 'https://github.com/test/sorting', - project_type: 'DESIGN_AND_DEVELOP', - }, - }, - }); - projectId = projectResponse.body.data.createProject.project_id; - const createNonprofitMutation = `mutation CreateNonprofit($input: CreateNonprofitInput!) { createNonprofit(input: $input) { nonprofit_id } }`; const np1 = await request(server) .post('/') @@ -109,7 +79,44 @@ beforeAll(async () => { }, }); nonprofit3Id = np3.body.data.createNonprofit.nonprofit_id; +}); +// Recreate chapter, project, and nonprofit_chapter_project records before each test +// This is necessary because the global beforeEach in tests/setup.ts deletes them +beforeEach(async () => { + // Recreate chapter + const chapterMutation = `mutation CreateChapter($input: CreateChapterInput!) { createChapter(input: $input) { chapter_id } }`; + const chapterResponse = await request(server) + .post('/') + .send({ + query: chapterMutation, + variables: { + input: { + name: 'Test Chapter for Sorting', + founded_date: '2020-01-01T00:00:00.000Z', + status_type: 'ACTIVE', + }, + }, + }); + chapterId = chapterResponse.body.data.createChapter.chapter_id; + + // Recreate project + const projectMutation = `mutation CreateProject($input: CreateProjectInput!) { createProject(input: $input) { project_id } }`; + const projectResponse = await request(server) + .post('/') + .send({ + query: projectMutation, + variables: { + input: { + name: 'Test Project for Sorting', + repo: 'https://github.com/test/sorting', + project_type: 'DESIGN_AND_DEVELOP', + }, + }, + }); + projectId = projectResponse.body.data.createProject.project_id; + + // Recreate nonprofit_chapter_project records await prisma.nonprofit_chapter_project.create({ data: { nonprofit_id: nonprofit1Id, @@ -149,15 +156,26 @@ beforeAll(async () => { }); afterAll(async () => { - await prisma.nonprofit_chapter_project.deleteMany({ - where: { nonprofit_id: { in: [nonprofit1Id, nonprofit2Id, nonprofit3Id] } }, - }); - await prisma.nonprofits.deleteMany({ - where: { nonprofit_id: { in: [nonprofit1Id, nonprofit2Id, nonprofit3Id] } }, - }); - await prisma.projects.deleteMany({ where: { project_id: projectId } }); - await prisma.chapters.deleteMany({ where: { chapter_id: chapterId } }); - await prisma.contacts.deleteMany({ where: { contact_id: contactId } }); + const nonprofitIds = [nonprofit1Id, nonprofit2Id, nonprofit3Id].filter( + (id) => id !== undefined + ); + if (nonprofitIds.length > 0) { + await prisma.nonprofit_chapter_project.deleteMany({ + where: { nonprofit_id: { in: nonprofitIds } }, + }); + await prisma.nonprofits.deleteMany({ + where: { nonprofit_id: { in: nonprofitIds } }, + }); + } + if (projectId) { + await prisma.projects.deleteMany({ where: { project_id: projectId } }); + } + if (chapterId) { + await prisma.chapters.deleteMany({ where: { chapter_id: chapterId } }); + } + if (contactId) { + await prisma.contacts.deleteMany({ where: { contact_id: contactId } }); + } server.close(); });