Skip to content

Commit

Permalink
refactor: api module types
Browse files Browse the repository at this point in the history
- Ensure that only values that meet requirements are returned
  • Loading branch information
sounmind committed Nov 14, 2024
1 parent 80c1560 commit d0de9b7
Show file tree
Hide file tree
Showing 9 changed files with 83 additions and 125 deletions.
4 changes: 2 additions & 2 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { fetchLeaderBoard } from "./services/store/storeService";
export type { MemberInfo, Submission } from "./services/common/types";
export type { Submission } from "./services/common/types";
export { getMembers } from "./services/store/storeService";
export type { Grade } from "./types";
25 changes: 15 additions & 10 deletions src/api/services/common/types.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
import type { Grade } from "../../types";

export type Cohort = number;

export type Member = {
export type MemberIdentity = {
id: string; // lowercase
name: string;
cohort: Cohort;
profileUrl?: string;
};

export type MemberInfo = Member & {
totalSubmissions: number;
progress: number;
grade: Grade;
submissions: Submission[];
};

export type Submission = {
memberId: string;
problemTitle: string;
language: string;
};

export interface Member {
id: string;
name: string;
/** 기수 (1기, 2기, 3기 ...) */
cohort: number;
/** Profile Image URL */
profileUrl?: string;
/** Unit: % */
progress: number;
grade: "SEED" | "SPROUT" | "SMALL_TREE" | "BIG_TREE";
/** Example: ["best-time-to-buy-and-sell-stock", "3sum", "climbing-stairs", ...] */
solvedProblems: string[];
}
10 changes: 5 additions & 5 deletions src/api/services/fetch/fetchService.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { Config } from "../../config/types";
import { createGitHubClient } from "../../infra/gitHub/gitHubClient";
import type { GitHubTree } from "../../infra/gitHub/types";
import type { Cohort, Member, Submission } from "../common/types";
import type { Cohort, MemberIdentity, Submission } from "../common/types";

export function createFetchService(config: Config) {
const gitHubClient = createGitHubClient(config.gitHub);

return {
fetchMembers: async (): Promise<Member[]> => {
fetchMembers: async (): Promise<MemberIdentity[]> => {
const teamNames = await gitHubClient.getTeamNames(
config.study.organization,
);
Expand All @@ -23,7 +23,7 @@ export function createFetchService(config: Config) {
const cohort = parseCohort(teamName, config.study.teamPrefix);

return members.map(
(member): Member => ({
(member): MemberIdentity => ({
id: member.login.toLocaleLowerCase(),
name: member.login,
profileUrl: member.avatar_url,
Expand Down Expand Up @@ -56,14 +56,14 @@ const parseCohort = (teamName: string, prefix: string): Cohort => {
return parseInt(teamName.replace(prefix, ""), 10);
};

const dropDuplicateMembers = (members: Member[]): Member[] => {
const dropDuplicateMembers = (members: MemberIdentity[]): MemberIdentity[] => {
const memberMap = members.reduce((acc, member) => {
const existingMember = acc.get(member.id);
if (!existingMember || member.cohort > existingMember.cohort) {
acc.set(member.id, member);
}
return acc;
}, new Map<string, Member>());
}, new Map<string, MemberIdentity>());

return Array.from(memberMap.values());
};
Expand Down
28 changes: 13 additions & 15 deletions src/api/services/process/processService.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { test, expect } from "vitest";
import { createProcessService } from "./processService";
import { mockConfig, mockMembers, mockSubmissions } from "../common/fixtures";
import { expect, test } from "vitest";
import { Grades } from "../../types";
import { mockConfig, mockMembers, mockSubmissions } from "../common/fixtures";
import { createProcessService } from "./processService";

const processService = createProcessService(mockConfig);

test("processService should initialize members correctly", () => {
test("initialize members correctly", () => {
// Act
const result = processService.analyzeMemberInfo(mockMembers, []);

Expand All @@ -14,8 +14,7 @@ test("processService should initialize members correctly", () => {
result.forEach((member) => {
expect(member).toEqual({
...mockMembers.find((m) => m.id === member.id),
totalSubmissions: 0,
submissions: [],
solvedProblems: [],
progress: 0,
grade: Grades.SEED,
});
Expand All @@ -30,10 +29,6 @@ test("processService should calculate submissions and progress correctly", () =>
const algoInfo = result.find((m) => m.id === "algo")!; // 2
const daleInfo = result.find((m) => m.id === "dale")!; // 1

// number of submissions
expect(algoInfo.totalSubmissions).toBe(2);
expect(daleInfo.totalSubmissions).toBe(1);

// progress percentage
expect(algoInfo.progress).toBe(50); // 2/4 * 100
expect(daleInfo.progress).toBe(25); // 1/4 * 100
Expand All @@ -42,12 +37,16 @@ test("processService should calculate submissions and progress correctly", () =>
test("processService should handle duplicate problem submissions", () => {
// Arrange
const duplicateSubmissions = [
...mockSubmissions,
{
memberId: "algo",
problemTitle: "problem1", // duplicate
problemTitle: "duplicated-problem",
language: "ts",
},
{
memberId: "algo",
problemTitle: "duplicated-problem",
language: "js",
},
];

// Act
Expand All @@ -57,9 +56,8 @@ test("processService should handle duplicate problem submissions", () => {
);

// Assert
const algoInfo = result.find((m) => m.id === "algo")!;
expect(algoInfo.totalSubmissions).toBe(2); // duplicates should be ignored
expect(algoInfo.submissions).toHaveLength(3); // but should be added to submissions
const algo = result.find((m) => m.id === "algo")!;
expect(algo.solvedProblems.length).toBe(1); // duplicates should be ignored
});

test("processService should assign correct grades based on submissions", () => {
Expand Down
45 changes: 23 additions & 22 deletions src/api/services/process/processService.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { Config } from "../../config/types";
import { type Grade, Grades } from "../../types";
import type { Member, MemberInfo, Submission } from "../common/types";
import type { Member, MemberIdentity, Submission } from "../common/types";

export function createProcessService(config: Config) {
return {
analyzeMemberInfo(
members: Member[],
members: MemberIdentity[],
submissions: Submission[],
): MemberInfo[] {
): Member[] {
const memberMap = initializeMemberMap(members);

updateSubmissions(memberMap, submissions);
calculateProgress(memberMap, config.study.totalProblemCount);
updateGrades(memberMap, config.study.gradeThresholds);
Expand All @@ -18,14 +19,15 @@ export function createProcessService(config: Config) {
};
}

const initializeMemberMap = (members: Member[]): Record<string, MemberInfo> => {
const memberMap: Record<string, MemberInfo> = {};
const initializeMemberMap = (
members: MemberIdentity[],
): Record<string, Member> => {
const memberMap: Record<string, Member> = {};

members.forEach((member) => {
memberMap[member.id] = {
...member,
totalSubmissions: 0,
submissions: [],
solvedProblems: [],
progress: 0,
grade: Grades.SEED,
};
Expand All @@ -35,43 +37,41 @@ const initializeMemberMap = (members: Member[]): Record<string, MemberInfo> => {
};

const updateSubmissions = (
memberMap: Record<string, MemberInfo>,
memberMap: Record<string, Member>,
submissions: Submission[],
): void => {
submissions.forEach((submission) => {
const member = memberMap[submission.memberId];
if (!member) return;

if (isNewProblem(member, submission)) {
member.totalSubmissions += 1;
}

member.submissions.push(submission);
member.solvedProblems.push(submission.problemTitle);
});
};

const isNewProblem = (member: MemberInfo, submission: Submission): boolean => {
return !member.submissions.some(
(s) => s.problemTitle === submission.problemTitle,
);
submissions.forEach((submission) => {
const member = memberMap[submission.memberId];
if (!member) return;

member.solvedProblems = Array.from(new Set(member.solvedProblems));
});
};

const calculateProgress = (
memberMap: Record<string, MemberInfo>,
memberMap: Record<string, Member>,
totalProblemCount: number,
): void => {
Object.values(memberMap).forEach((member) => {
member.progress =
Math.round((member.totalSubmissions / totalProblemCount) * 1000) / 10;
Math.round((member.solvedProblems.length / totalProblemCount) * 1000) /
10;
});
};

const updateGrades = (
memberMap: Record<string, MemberInfo>,
memberMap: Record<string, Member>,
thresholds: [Grade, number][],
): void => {
Object.values(memberMap).forEach((member) => {
member.grade = determineGrade(member.totalSubmissions, thresholds);
member.grade = determineGrade(member.solvedProblems.length, thresholds);
});
};

Expand All @@ -83,5 +83,6 @@ const determineGrade = (
const grade = thresholds.find(
([, threshold]) => totalSubmissions >= threshold,
);

return grade ? grade[0] : Grades.SEED;
};
20 changes: 10 additions & 10 deletions src/api/services/store/storeService.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { test, expect, beforeEach, vi } from "vitest";
import { beforeEach, expect, test, vi } from "vitest";
import { mockMembers } from "../common/fixtures";
import { createFetchService } from "../fetch/fetchService";
import { createProcessService } from "../process/processService";
import { mockConfig, mockMembers } from "../common/fixtures";
import { fetchLeaderBoard } from "./storeService";
import { getMembers } from "./storeService";

// Mock services
const mockFetchMembers = vi.fn();
Expand Down Expand Up @@ -30,7 +30,7 @@ beforeEach(() => {

test("should fetch and process data correctly", async () => {
// Act
const result = await fetchLeaderBoard(mockConfig);
const result = await getMembers();

// Assert
expect(mockFetchMembers).toHaveBeenCalledTimes(1);
Expand All @@ -39,33 +39,33 @@ test("should fetch and process data correctly", async () => {
expect(result).toEqual(mockMembers);
});

test("fetchLeaderBoard should throw error when fetch fails", async () => {
test("getMembers should throw error when fetch fails", async () => {
// Arrange
mockFetchMembers.mockRejectedValue(new Error("Fetch failed"));

// Act & Assert
await expect(fetchLeaderBoard(mockConfig)).rejects.toThrow("Fetch failed");
await expect(getMembers()).rejects.toThrow("Fetch failed");
});

test("fetchLeaderBoard should handle process service errors", async () => {
test("getMembers should handle process service errors", async () => {
// Arrange
mockAnalyzeMemberInfo.mockImplementation(() => {
throw new Error("Process failed");
});

// Act & Assert
await expect(fetchLeaderBoard(mockConfig)).rejects.toThrow("Process failed");
await expect(getMembers()).rejects.toThrow("Process failed");
});

test("fetchLeaderBoard should pass correct parameters to process service", async () => {
test("getMembers should pass correct parameters to process service", async () => {
// Arrange
const mockMembers = [{ id: "1", name: "Test" }];
const mockSubmissions = [{ id: "1", score: 100 }];
mockFetchMembers.mockResolvedValue(mockMembers);
mockFetchSubmissions.mockResolvedValue(mockSubmissions);

// Act
await fetchLeaderBoard(mockConfig);
await getMembers();

// Assert
expect(mockAnalyzeMemberInfo).toHaveBeenCalledWith(
Expand Down
11 changes: 6 additions & 5 deletions src/api/services/store/storeService.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { Config } from "../../config/types";
import { CONFIG } from "../../config";
import type { Member } from "../common/types";
import { createFetchService } from "../fetch/fetchService";
import { createProcessService } from "../process/processService";

export async function fetchLeaderBoard(config: Config) {
const fetchService = createFetchService(config);
const processService = createProcessService(config);
export async function getMembers(): Promise<Member[]> {
const fetchService = createFetchService(CONFIG);
const processService = createProcessService(CONFIG);

const [members, submissions] = await Promise.all([
fetchService.fetchMembers(),
fetchService.fetchSubmissions(config.study.repository),
fetchService.fetchSubmissions(CONFIG.study.repository),
]);

return processService.analyzeMemberInfo(members, submissions);
Expand Down
30 changes: 6 additions & 24 deletions src/hooks/useMembers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,28 @@ import { faker } from "@faker-js/faker";
import { renderHook, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";

import type { MemberInfo } from "../api";
import useMembers, { type Member } from "./useMembers";
import type { Member } from "../api/services/common/types";
import useMembers from "./useMembers";

test("fetch member info successfully and update state", async () => {
const mockMemberInfos: MemberInfo[] = Array.from({ length: 5 }, () => ({
const expectedMembers: Member[] = Array.from({ length: 5 }, () => ({
id: faker.string.uuid(),
name: faker.person.fullName(),
cohort: faker.number.int({ min: 1, max: 10 }),
profileUrl: faker.internet.url(),
totalSubmissions: faker.number.int({ min: 0, max: 100 }),
progress: faker.number.int({ min: 0, max: 100 }),
grade: faker.helpers.arrayElement([
"SEED",
"SPROUT",
"SMALL_TREE",
"BIG_TREE",
]),
submissions: Array.from(
{ length: faker.number.int({ min: 0, max: 5 }) },
(): MemberInfo["submissions"][number] => ({
problemTitle: faker.lorem.words(3),
memberId: faker.string.uuid(),
language: faker.helpers.arrayElement(["javascript", "python", "java"]),
}),
solvedProblems: Array.from({ length: 5 }, () =>
faker.lorem.words(3).replaceAll(" ", "-"),
),
}));

const expectedMembers: Member[] = mockMemberInfos.map((memberInfo) => ({
id: memberInfo.id,
name: memberInfo.name,
cohort: memberInfo.cohort,
profileUrl: memberInfo.profileUrl,
progress: memberInfo.progress,
grade: memberInfo.grade,
solvedProblems: memberInfo.submissions.map(
(submission) => submission.problemTitle,
),
}));

const getMembers = vi.fn().mockResolvedValue(mockMemberInfos);
const getMembers = vi.fn().mockResolvedValue(expectedMembers);

const { result } = renderHook(() => useMembers({ getMembers }));

Expand Down
Loading

0 comments on commit d0de9b7

Please sign in to comment.