From 8fb26e441517a62c413a3c3307d7efe879ee7d1d Mon Sep 17 00:00:00 2001 From: Estevam Furtado Date: Mon, 14 Oct 2024 20:55:50 -0300 Subject: [PATCH] Moved db operations to use cases and added tests --- .../ranking/degrees/[degree]/[class]/index.ts | 66 ++------- pages/api/ranking/index.ts | 51 +------ use_cases/getDegreeClassData.test.ts | 129 ++++++++++++++++++ use_cases/getDegreeClassData.ts | 59 ++++++++ use_cases/getRankingList.test.ts | 111 +++++++++++++++ use_cases/getRankingList.ts | 80 +++++++++++ 6 files changed, 394 insertions(+), 102 deletions(-) create mode 100644 use_cases/getDegreeClassData.test.ts create mode 100644 use_cases/getDegreeClassData.ts create mode 100644 use_cases/getRankingList.test.ts create mode 100644 use_cases/getRankingList.ts diff --git a/pages/api/ranking/degrees/[degree]/[class]/index.ts b/pages/api/ranking/degrees/[degree]/[class]/index.ts index 123f4a0..96938bb 100644 --- a/pages/api/ranking/degrees/[degree]/[class]/index.ts +++ b/pages/api/ranking/degrees/[degree]/[class]/index.ts @@ -3,7 +3,7 @@ import runRequestWithDIContainer from "../../../../../../middlewares/diContainer import { PrismaClient } from "@prisma/client"; import { DIContainerNextApiRequest } from "../../../../../../dependency_injection/DIContainerNextApiRequest"; import { RANKING_INITIAL_DATA } from "../../../contants"; -import { GetClassData } from "../../../types"; +import { getDegreeClassData } from "../../../../../../use_cases/getDegreeClassData"; export default async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === "GET") { @@ -17,10 +17,13 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { async function run(req: DIContainerNextApiRequest, res: NextApiResponse) { const prismaClient: PrismaClient = req.scope.resolve("dbClient"); try { - const rankingData = await getData( - prismaClient, - req.query.degree as string, - req.query.class as string + const rankingData = await getDegreeClassData( + { + dbClient: prismaClient, + degree: req.query.degree as string, + year: req.query.class as string, + initialDate: RANKING_INITIAL_DATA + } ); res.statusCode = 200; res.json(rankingData); @@ -29,55 +32,4 @@ async function run(req: DIContainerNextApiRequest, res: NextApiResponse) { res.statusCode = 500; return; } -} - -async function getData( - prisma: PrismaClient, - degree: string, - year: string -): Promise { - const minYear = Math.floor(Number(year) / 5) * 5; - const maxYear = minYear + 5; - - const result: { - id: number; - firstName: string; - lastName: number; - url: string; - amount: number; - year: number; - }[] = await prisma.$queryRaw` - SELECT - u."id", - u."first_name" AS "firstName", - u."last_name" AS "lastName", - u."url", - SUM(c."amount_in_cents")/100 AS "amount", - u."admission_year" AS "year" - FROM "users" u - LEFT JOIN "contributions" c ON u."id" = c."userId" - WHERE - u."degree" = ${degree} - AND u."admission_year" >= ${minYear} - AND u."admission_year" < ${maxYear} - AND c."state" = 'completed' - AND c."createdAt" > ${RANKING_INITIAL_DATA} - GROUP BY u."id", u."first_name", u."last_name", u."url", u."admission_year" - `; - - const amount = result.reduce((acc, row) => acc + row.amount, 0); - const numberOfDonors = result.length; - const donors: GetClassData["donors"] = result.map((row) => { - return { - name: `${row.firstName} ${row.lastName}`, - url: row.url, - year: row.year, - }; - }); - - return { - amount, - numberOfDonors, - donors, - }; -} +} \ No newline at end of file diff --git a/pages/api/ranking/index.ts b/pages/api/ranking/index.ts index 30eb3fe..ed80107 100644 --- a/pages/api/ranking/index.ts +++ b/pages/api/ranking/index.ts @@ -3,7 +3,7 @@ import runRequestWithDIContainer from "../../../middlewares/diContainerMiddlewar import { PrismaClient } from "@prisma/client"; import { DIContainerNextApiRequest } from "../../../dependency_injection/DIContainerNextApiRequest"; import { RANKING_INITIAL_DATA } from "./contants"; -import { GetRankingData } from "./types"; +import { getRankingList } from "../../../use_cases/getRankingList"; export default async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === "GET") { @@ -17,8 +17,10 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { async function run(req: DIContainerNextApiRequest, res: NextApiResponse) { const prismaClient: PrismaClient = req.scope.resolve("dbClient"); try { - console.log(req.query); - const rankingData = await getRankingData(prismaClient); + const rankingData = await getRankingList({ + dbClient: prismaClient, + initialDate: RANKING_INITIAL_DATA + }); res.statusCode = 200; res.json(rankingData); } catch (error) { @@ -26,45 +28,4 @@ async function run(req: DIContainerNextApiRequest, res: NextApiResponse) { res.statusCode = 500; return; } -} - -async function getRankingData(prisma: PrismaClient): Promise { - const result: { - period: number; - degree: string; - amount: number; - donors: number; - }[] = await prisma.$queryRaw` - SELECT - FLOOR((u."admission_year") / 5) * 5 AS "period", - u."degree", - SUM(c."amount_in_cents")/100 AS "amount", - COUNT(DISTINCT c."userId") AS "donors" - FROM "users" u - JOIN "contributions" c ON u."id" = c."userId" - WHERE - c."state" = 'completed' - AND c."createdAt" > ${RANKING_INITIAL_DATA} - GROUP BY "period", u."degree" - ORDER BY "amount" DESC; - `; - - const amount = result.reduce((acc, row) => acc + row.amount, 0); - const numberOfDonors = result.reduce((acc, row) => acc + row.donors, 0); - const ranking: GetRankingData["ranking"] = result.map((row, index) => { - return { - position: index + 1, - degree: row.degree, - initialYear: row.period, - finalYear: row.period + 4, - amount: row.amount, - numberOfDonors: row.donors, - }; - }); - - return { - amount, - numberOfDonors, - ranking, - }; -} +} \ No newline at end of file diff --git a/use_cases/getDegreeClassData.test.ts b/use_cases/getDegreeClassData.test.ts new file mode 100644 index 0000000..3913b8a --- /dev/null +++ b/use_cases/getDegreeClassData.test.ts @@ -0,0 +1,129 @@ +import { PrismaClient } from "@prisma/client"; +import { getDegreeClassData } from "./getDegreeClassData"; +import createContribution from "./createContribution"; +import completeContribution from "./completeContribution"; +import createUser from "./createUser"; + +let prisma: PrismaClient; + +beforeAll(async () => { + prisma = new PrismaClient(); + + await prisma.contribution.deleteMany(); + await prisma.user.deleteMany(); + + const user1 = await createUser({ + dbClient: prisma, + email: "john@example.com", + firstName: "John", + lastName: "Doe", + university: "UFRJ", + degree: "Industrial Engineering", + admissionYear: 2020, + url: "http://example.com/john", + birthday: new Date("1990-01-01"), + tutorshipInterest: false, + mentorshipInterest: false, + volunteeringInterest: false + }); + + const contribution1 = await createContribution({ + dbClient: prisma, + amountInCents: 50_00, + email: user1.email, + }); + + await completeContribution({ + dbClient: prisma, + contributionId: contribution1.id, + externalId: "123456", + }); + + const user2 = await createUser({ + dbClient: prisma, + email: "jane@example.com", + firstName: "Jane", + lastName: "Smith", + university: "UFRJ", + degree: "Industrial Engineering", + admissionYear: 2021, + url: "http://example.com/jane", + birthday: new Date("1990-01-01"), + tutorshipInterest: false, + mentorshipInterest: false, + volunteeringInterest: false + }); + + const contribution2 = await createContribution({ + dbClient: prisma, + amountInCents: 30_00, + email: user2.email, + }); + + await completeContribution({ + dbClient: prisma, + contributionId: contribution2.id, + externalId: "123456", + }); + + const user3 = await createUser({ + dbClient: prisma, + email: "steve@example.com", + firstName: "Steve", + lastName: "Jobs", + university: "UFRJ", + degree: "Industrial Engineering", + admissionYear: 2015, + url: "http://example.com/steve", + birthday: new Date("1955-02-24"), + tutorshipInterest: false, + mentorshipInterest: false, + volunteeringInterest: false + }); + + + const contribution3 = await createContribution({ + dbClient: prisma, + amountInCents: 20_00, + email: user3.email, + }); + + await completeContribution({ + dbClient: prisma, + contributionId: contribution3.id, + externalId: "123456", + }); +}); + +afterAll(async () => { + await prisma.$disconnect(); +}); + +describe("getDegreeClassData", () => { + it("should return the correct data for a given degree and year (5y window)", async () => { + const initialDate = new Date("2023-01-01"); + const result = await getDegreeClassData({ + dbClient: prisma, + year: "2020", + degree: "Industrial Engineering", + initialDate, + }); + + expect(result).toEqual({ + amount: 80, + numberOfDonors: 2, + donors: [ + { + name: "John Doe", + year: 2020, + url: "http://example.com/john", + }, + { + name: "Jane Smith", + year: 2021, + url: "http://example.com/jane", + }, + ], + }); + }); +}); diff --git a/use_cases/getDegreeClassData.ts b/use_cases/getDegreeClassData.ts new file mode 100644 index 0000000..2df1ac2 --- /dev/null +++ b/use_cases/getDegreeClassData.ts @@ -0,0 +1,59 @@ +import { PrismaClient } from "@prisma/client"; +import { GetClassData } from "../pages/api/ranking/types"; + +type GetDegreeClassDataArgs = { + dbClient: PrismaClient; + degree: string; + year: string; + initialDate: Date; +} + +export async function getDegreeClassData( + args: GetDegreeClassDataArgs + ): Promise { + const minYear = Math.floor(Number(args.year) / 5) * 5; + const maxYear = minYear + 5; + + const result: { + id: number; + firstName: string; + lastName: number; + url: string; + amount: number; + year: number; + }[] = await args.dbClient.$queryRaw` + SELECT + u."id", + u."first_name" AS "firstName", + u."last_name" AS "lastName", + u."url", + SUM(c."amount_in_cents")/100 AS "amount", + u."admission_year" AS "year" + FROM "users" u + LEFT JOIN "contributions" c ON u."id" = c."userId" + WHERE + u."degree" = ${args.degree} + AND u."admission_year" >= ${minYear} + AND u."admission_year" < ${maxYear} + AND c."state" = 'completed' + AND c."createdAt" > ${args.initialDate} + GROUP BY u."id", u."first_name", u."last_name", u."url", u."admission_year" + `; + + const amount = result.reduce((acc, row) => acc + row.amount, 0); + const numberOfDonors = result.length; + const donors: GetClassData["donors"] = result.map((row) => { + return { + name: `${row.firstName} ${row.lastName}`, + url: row.url, + year: row.year, + }; + }); + + return { + amount, + numberOfDonors, + donors, + }; + } + \ No newline at end of file diff --git a/use_cases/getRankingList.test.ts b/use_cases/getRankingList.test.ts new file mode 100644 index 0000000..c083ee7 --- /dev/null +++ b/use_cases/getRankingList.test.ts @@ -0,0 +1,111 @@ +import { PrismaClient } from "@prisma/client"; +import { getRankingList } from "./getRankingList"; +import createContribution from "./createContribution"; +import completeContribution from "./completeContribution"; +import createUser from "./createUser"; + +let prisma: PrismaClient; + +beforeAll(async () => { + prisma = new PrismaClient(); + + await prisma.contribution.deleteMany(); + await prisma.user.deleteMany(); + + // Create users and contributions + const users = [ + { email: "user1@example.com", degree: "Computer Science", admissionYear: 2020 }, + { email: "user2@example.com", degree: "Computer Science", admissionYear: 2021 }, + { email: "user3@example.com", degree: "Industrial Engineering", admissionYear: 2020 }, + ]; + + for (const user of users) { + await createUser({ + dbClient: prisma, + email: user.email, + firstName: "Test", + lastName: "User", + university: "UFRJ", + degree: user.degree, + admissionYear: user.admissionYear, + url: `http://example.com/${user.email}`, + birthday: new Date("1990-01-01"), + tutorshipInterest: false, + mentorshipInterest: false, + volunteeringInterest: false + }); + + const contribution = await createContribution({ + dbClient: prisma, + amountInCents: 100_00, + email: user.email, + }); + + await completeContribution({ + dbClient: prisma, + contributionId: contribution.id, + externalId: "test123", + }); + } +}); + +afterAll(async () => { + await prisma.$disconnect(); +}); + +describe("getRankingList", () => { + const initialDate = new Date("2023-01-01"); + + it("should return correct ranking data without degree filter", async () => { + const result = await getRankingList({ + dbClient: prisma, + initialDate, + }); + + expect(result).toEqual({ + amount: 300, + numberOfDonors: 3, + ranking: [ + { + position: 1, + degree: "Computer Science", + initialYear: 2020, + finalYear: 2024, + amount: 200, + numberOfDonors: 2, + }, + { + position: 2, + degree: "Industrial Engineering", + initialYear: 2020, + finalYear: 2024, + amount: 100, + numberOfDonors: 1, + }, + ], + }); + }); + + it("should return correct ranking data with degree filter", async () => { + const result = await getRankingList({ + dbClient: prisma, + initialDate, + degree: "Computer Science", + }); + + expect(result).toEqual({ + amount: 200, + numberOfDonors: 2, + ranking: [ + { + position: 1, + degree: "Computer Science", + initialYear: 2020, + finalYear: 2024, + amount: 200, + numberOfDonors: 2, + }, + ], + }); + }); +}); diff --git a/use_cases/getRankingList.ts b/use_cases/getRankingList.ts new file mode 100644 index 0000000..7bf8ed7 --- /dev/null +++ b/use_cases/getRankingList.ts @@ -0,0 +1,80 @@ +import { PrismaClient } from "@prisma/client"; +import { GetRankingData } from "../pages/api/ranking/types"; + +type GetRankingListArgs = { + dbClient: PrismaClient; + initialDate: Date; + degree?: string; +} + +export async function getRankingList(args: GetRankingListArgs): Promise { + + // TODO(estevam): use pagination + // Could not use the same query for both because of the way Prisma handles $queryRaw + const result = args.degree ? await getDegreeRankingListQuery(args) : await getRankingListQuery(args); + + const amount = result.reduce((acc, row) => acc + row.amount, 0); + const numberOfDonors = result.reduce((acc, row) => acc + row.donors, 0); + const ranking: GetRankingData["ranking"] = result.map((row, index) => { + return { + position: index + 1, + degree: row.degree, + initialYear: row.period, + finalYear: row.period + 4, + amount: row.amount, + numberOfDonors: row.donors, + }; + }); + + return { + amount, + numberOfDonors, + ranking, + }; +} + +async function getRankingListQuery(args: GetRankingListArgs): Promise<{ + period: number; + degree: string; + amount: number; + donors: number; +}[]> { + return args.dbClient.$queryRaw` + SELECT + FLOOR((u."admission_year") / 5) * 5 AS "period", + u."degree", + SUM(c."amount_in_cents")/100 AS "amount", + COUNT(DISTINCT c."userId") AS "donors" + FROM "users" u + JOIN "contributions" c ON u."id" = c."userId" + WHERE + c."state" = 'completed' + AND c."createdAt" > ${args.initialDate} + GROUP BY "period", u."degree" + ORDER BY "amount" DESC; + `; +} + +async function getDegreeRankingListQuery(args: GetRankingListArgs): Promise<{ + period: number; + degree: string; + amount: number; + donors: number; +}[]> { + return args.dbClient.$queryRaw` + SELECT + FLOOR((u."admission_year") / 5) * 5 AS "period", + u."degree", + SUM(c."amount_in_cents")/100 AS "amount", + COUNT(DISTINCT c."userId") AS "donors" + FROM "users" u + JOIN "contributions" c ON u."id" = c."userId" + WHERE + c."state" = 'completed' + AND c."createdAt" > ${args.initialDate} + AND u."degree" = ${args.degree} + GROUP BY "period", u."degree" + ORDER BY "amount" DESC; + `; +} +