diff --git a/src/api/config/index.ts b/src/api/config/index.ts index 34f3b8f..0535c08 100644 --- a/src/api/config/index.ts +++ b/src/api/config/index.ts @@ -1,23 +1,15 @@ -import type { Grade } from "../services/common/types"; +import type { Grade } from "../services/types"; import type { Config } from "./types"; export const CONFIG: Config = { - study: { - organization: "DaleStudy", - repository: "leetcode-study", - branchName: "main", - teamPrefix: "leetcode", - totalProblemCount: 75, - gradeThresholds: [ - ["BIG_TREE", 70], - ["SMALL_TREE", 60], - ["SPROUT", 50], - ["SEED", 0], - ] as [Grade, number][], - }, - gitHub: { - baseUrl: "https://api.github.com", - mediaType: "application/vnd.github+json", - token: import.meta.env.VITE_GITHUB_API_TOKEN, - }, + totalProblemCount: 75, + gradeThresholds: [ + ["TREE", 70], // 나무 + ["FRUIT", 60], // 열매 + ["BRANCH", 45], // 가지 + ["LEAF", 30], // 잎새 + ["SPROUT", 15], // 새싹 + ["SEED", 0], // 씨앗 + ] as [Grade, number][], + gitHubToken: import.meta.env.VITE_GITHUB_API_TOKEN, } as const; diff --git a/src/api/config/types.ts b/src/api/config/types.ts index 80f7ace..157597e 100644 --- a/src/api/config/types.ts +++ b/src/api/config/types.ts @@ -1,21 +1,7 @@ -import type { Grade } from "../services/common/types"; +import type { Grade } from "../services/types"; -export type StudyConfig = { - organization: string; - repository: string; - branchName: string; - teamPrefix: string; +export type Config = { totalProblemCount: number; gradeThresholds: [Grade, number][]; -}; - -export type GitHubConfig = { - baseUrl: string; - mediaType: string; - token: string; -}; - -export type Config = { - study: StudyConfig; - gitHub: GitHubConfig; + gitHubToken: string; }; diff --git a/src/api/services/store/storeService.test.ts b/src/api/getMembers.test.ts similarity index 81% rename from src/api/services/store/storeService.test.ts rename to src/api/getMembers.test.ts index 2f36c9a..58f3a38 100644 --- a/src/api/services/store/storeService.test.ts +++ b/src/api/getMembers.test.ts @@ -1,21 +1,25 @@ import { beforeEach, expect, test, vi } from "vitest"; -import { mockMembers } from "../common/fixtures"; -import { createFetchService } from "../fetch/fetchService"; -import { createProcessService } from "../process/processService"; -import { getMembers } from "./storeService"; +import { mock } from "vitest-mock-extended"; +import { createFetchService } from "./services/fetch/fetchService"; +import { createProcessService } from "./services/process/processService"; +import { getMembers } from "./getMembers"; +import { type Member } from "./services/types"; + +// Mock data +const mockMembers = mock(); // Mock services const mockFetchMembers = vi.fn(); const mockFetchSubmissions = vi.fn(); const mockGetMembers = vi.fn(); -vi.mock("../fetch/fetchService"); +vi.mock("./services/fetch/fetchService"); vi.mocked(createFetchService).mockReturnValue({ fetchMembers: mockFetchMembers, fetchSubmissions: mockFetchSubmissions, }); -vi.mock("../process/processService"); +vi.mock("./services/process/processService"); vi.mocked(createProcessService).mockReturnValue({ getMembers: mockGetMembers, }); diff --git a/src/api/services/store/storeService.ts b/src/api/getMembers.ts similarity index 54% rename from src/api/services/store/storeService.ts rename to src/api/getMembers.ts index fe36c3e..3f3bdc7 100644 --- a/src/api/services/store/storeService.ts +++ b/src/api/getMembers.ts @@ -1,7 +1,7 @@ -import { CONFIG } from "../../config"; -import type { Member } from "../common/types"; -import { createFetchService } from "../fetch/fetchService"; -import { createProcessService } from "../process/processService"; +import { CONFIG } from "./config"; +import { createFetchService } from "./services/fetch/fetchService"; +import { createProcessService } from "./services/process/processService"; +import { type Member } from "./services/types"; export async function getMembers(): Promise { const fetchService = createFetchService(CONFIG); @@ -9,7 +9,7 @@ export async function getMembers(): Promise { const [memberIdentities, submissions] = await Promise.all([ fetchService.fetchMembers(), - fetchService.fetchSubmissions(CONFIG.study.repository), + fetchService.fetchSubmissions("leetcode-study"), ]); return processService.getMembers(memberIdentities, submissions); diff --git a/src/api/index.ts b/src/api/index.ts deleted file mode 100644 index ae3bdc7..0000000 --- a/src/api/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { Grade, Submission } from "./services/common/types"; -export { getMembers } from "./services/store/storeService"; diff --git a/src/api/infra/gitHub/fixtures.ts b/src/api/infra/gitHub/fixtures.ts deleted file mode 100644 index 91c70b5..0000000 --- a/src/api/infra/gitHub/fixtures.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { GitHubTeam, GitHubMember, GitHubTree } from "./types"; - -export const mockGitHubTeams: GitHubTeam[] = [ - { - name: "leetcode01", - id: 1, - node_id: "T", - slug: "leetcode01", - description: "리트코드 스터디", - privacy: "closed", - notification_setting: "notifications_enabled", - url: "some-url", - html_url: "some-url", - members_url: "members-url", - repositories_url: "repositories-url", - permission: "pull", - parent: null, - }, - { - name: "leetcode02", - id: 2, - node_id: "D", - slug: "leetcode", - description: "리트코드 스터디", - privacy: "closed", - notification_setting: "notifications_enabled", - url: "some-url", - html_url: "some-url", - members_url: "members-url", - repositories_url: "repositories-url", - permission: "pull", - parent: null, - }, -]; - -export const mockGitHubMembers: GitHubMember[] = [ - { - login: "D", - id: 1, - node_id: "M", - avatar_url: "avatar-url", - gravatar_id: "", - url: "some-url", - html_url: "some-url", - followers_url: "some-url", - following_url: "some-url", - gists_url: "some-url", - starred_url: "some-url", - subscriptions_url: "some-url", - organizations_url: "some-url", - repos_url: "some-url", - events_url: "some-url", - received_events_url: "some-url", - type: "User", - user_view_type: "public", - site_admin: false, - }, - { - login: "S", - id: 2, - node_id: "E", - avatar_url: "avatar-url", - gravatar_id: "", - url: "some-url", - html_url: "some-url", - followers_url: "some-url", - following_url: "some-url", - gists_url: "some-url", - starred_url: "some-url", - subscriptions_url: "some-url", - organizations_url: "some-url", - repos_url: "some-url", - events_url: "some-url", - received_events_url: "some-url", - type: "User", - user_view_type: "public", - site_admin: false, - }, -]; - -export const mockGitHubTree: GitHubTree[] = [ - { - path: "best-time-to-buy-and-sell-stock/test.java", - type: "blob", - mode: "100644", - sha: "1", - size: 0, - url: "some-url", - }, - { - path: "best-time-to-buy-and-sell-stock/test2.java", - type: "blob", - mode: "100644", - sha: "2", - size: 0, - url: "some-url", - }, -]; diff --git a/src/api/infra/gitHub/gitHubClient.test.ts b/src/api/infra/gitHub/gitHubClient.test.ts index 414b31c..6be30d6 100644 --- a/src/api/infra/gitHub/gitHubClient.test.ts +++ b/src/api/infra/gitHub/gitHubClient.test.ts @@ -1,12 +1,12 @@ import { test, expect, beforeEach, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; import { createGitHubClient } from "./gitHubClient"; -import { CONFIG } from "../../config"; -import { mockGitHubTeams, mockGitHubMembers, mockGitHubTree } from "./fixtures"; +import { GitHubMember, GitHubTeam, GitHubTree } from "./types"; -const mockConfig = { - ...CONFIG.gitHub, - token: "test-token", -}; +// Mock data +const mockGitHubTeams = mock(); +const mockGitHubMembers = mock(); +const mockGitHubTrees = mock(); const mockFetch = vi.fn(); global.fetch = mockFetch; @@ -21,11 +21,11 @@ test("getTeamNames should fetch and return team names", async () => { ok: true, json: () => Promise.resolve(mockGitHubTeams), }); - const client = createGitHubClient(mockConfig); - const expectedUrl = `${mockConfig.baseUrl}/orgs/test-org/teams`; + const client = createGitHubClient("test-token"); + const expectedUrl = `https://api.github.com/orgs/test-org/teams`; const expectedHeaders = { - Accept: mockConfig.mediaType, - Authorization: `token ${mockConfig.token}`, + Accept: "application/vnd.github+json", + Authorization: "token test-token", }; // Act @@ -35,7 +35,7 @@ test("getTeamNames should fetch and return team names", async () => { expect(mockFetch).toHaveBeenCalledWith(expectedUrl, { headers: expectedHeaders, }); - expect(result).toEqual(["leetcode01", "leetcode02"]); + expect(result).toEqual(mockGitHubTeams.map((team) => team.name)); }); test("getTeamNames should throw error when fetch fails", async () => { @@ -45,7 +45,7 @@ test("getTeamNames should throw error when fetch fails", async () => { status: 404, statusText: "Not Found", }); - const client = createGitHubClient(mockConfig); + const client = createGitHubClient("test-token"); // Act & Assert await expect(client.getTeamNames("test-org")).rejects.toThrow( @@ -59,11 +59,11 @@ test("getTeamMembers should fetch and return team members", async () => { ok: true, json: () => Promise.resolve(mockGitHubMembers), }); - const client = createGitHubClient(mockConfig); - const expectedUrl = `${mockConfig.baseUrl}/orgs/test-org/teams/test-team/members`; + const client = createGitHubClient("test-token"); + const expectedUrl = `https://api.github.com/orgs/test-org/teams/test-team/members`; const expectedHeaders = { - Accept: mockConfig.mediaType, - Authorization: `token ${mockConfig.token}`, + Accept: "application/vnd.github+json", + Authorization: "token test-token", }; // Act @@ -80,12 +80,13 @@ test("getDirectoryTree should fetch and return directory tree", async () => { // Arrange mockFetch.mockResolvedValue({ ok: true, - json: () => Promise.resolve({ tree: mockGitHubTree }), + json: () => Promise.resolve({ tree: mockGitHubTrees }), }); - const client = createGitHubClient(mockConfig); - const expectedUrl = `${mockConfig.baseUrl}/repos/test-owner/test-repo/git/trees/main?recursive=1`; + const client = createGitHubClient("test-token"); + const expectedUrl = `https://api.github.com/repos/test-owner/test-repo/git/trees/main?recursive=1`; const expectedHeaders = { - Accept: mockConfig.mediaType, + Accept: "application/vnd.github+json", + Authorization: "token test-token", }; // Act @@ -99,7 +100,7 @@ test("getDirectoryTree should fetch and return directory tree", async () => { expect(mockFetch).toHaveBeenCalledWith(expectedUrl, { headers: expectedHeaders, }); - expect(result).toEqual(mockGitHubTree); + expect(result).toEqual(mockGitHubTrees); }); test("error should include detailed information", async () => { @@ -111,8 +112,8 @@ test("error should include detailed information", async () => { status, statusText, }); - const client = createGitHubClient(mockConfig); - const expectedErrorMessage = `Failed to fetch url: ${mockConfig.baseUrl}/orgs/test-org/teams, status: ${status}, statusText: ${statusText}`; + const client = createGitHubClient("test-token"); + const expectedErrorMessage = `Failed to fetch url: https://api.github.com/orgs/test-org/teams, status: ${status}, statusText: ${statusText}`; // Act & Assert await expect(client.getTeamNames("test-org")).rejects.toThrow( diff --git a/src/api/infra/gitHub/gitHubClient.ts b/src/api/infra/gitHub/gitHubClient.ts index 2fb4059..1e8ae60 100644 --- a/src/api/infra/gitHub/gitHubClient.ts +++ b/src/api/infra/gitHub/gitHubClient.ts @@ -1,4 +1,3 @@ -import type { GitHubConfig } from "../../config/types"; import type { GitHubMember, GitHubTeam, @@ -6,10 +5,10 @@ import type { GitHubTreeResponse, } from "./types"; -export function createGitHubClient(config: GitHubConfig) { - const request = async (url: string, token?: string): Promise => { +export function createGitHubClient(token: string) { + const request = async (url: string): Promise => { const headers: Record = { - Accept: config.mediaType, + Accept: "application/vnd.github+json", }; if (token) { @@ -29,18 +28,16 @@ export function createGitHubClient(config: GitHubConfig) { return { getTeamNames: async (organization: string): Promise => - request( - `${config.baseUrl}/orgs/${organization}/teams`, - config.token, - ).then((response) => (response as GitHubTeam[]).map((team) => team.name)), + request(`https://api.github.com/orgs/${organization}/teams`).then( + (response) => (response as GitHubTeam[]).map((team) => team.name), + ), getTeamMembers: async ( organization: string, teamName: string, ): Promise => request( - `${config.baseUrl}/orgs/${organization}/teams/${teamName}/members`, - config.token, + `https://api.github.com/orgs/${organization}/teams/${teamName}/members`, ).then((response) => response as GitHubMember[]), getDirectoryTree: async ( @@ -50,7 +47,7 @@ export function createGitHubClient(config: GitHubConfig) { recursive = 1, ): Promise => request( - `${config.baseUrl}/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=${recursive}`, + `https://api.github.com/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=${recursive}`, ).then((response) => (response as GitHubTreeResponse).tree), }; } diff --git a/src/api/services/common/fixtures.ts b/src/api/services/common/fixtures.ts deleted file mode 100644 index 1c5088c..0000000 --- a/src/api/services/common/fixtures.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { faker } from "@faker-js/faker"; - -import { problems } from "../../../constants/problems"; -import type { StudyConfig } from "../../config/types"; -import type { - GitHubMember, - GitHubTeam, - GitHubTree, -} from "../../infra/gitHub/types"; -import { - Grade, - type Member, - type MemberIdentity, - type Submission, -} from "./types"; - -export const dummyStudyConfig: StudyConfig = { - organization: "test-org", - repository: "test-repo", - branchName: "main", - teamPrefix: "algodale", - totalProblemCount: 4, - gradeThresholds: [ - ["BIG_TREE", 3], - ["SMALL_TREE", 2], - ["SPROUT", 1], - ["SEED", 0], - ] as [Grade, number][], -}; - -export const dummyGithubConfig = { - baseUrl: "https://api.github.com", - mediaType: "application/vnd.github+json", - token: "test-token", -}; - -export const dummyConfig = { - study: dummyStudyConfig, - gitHub: dummyGithubConfig, -}; - -export const mockGitHubTeams: GitHubTeam[] = [ - { - name: "algodale01", - id: 1, - node_id: "T", - slug: "algodale01", - description: "스터디", - privacy: "closed", - notification_setting: "notifications_enabled", - url: "some-url", - html_url: "some-url", - members_url: "members-url", - repositories_url: "repositories-url", - permission: "pull", - parent: null, - }, - { - name: "algodale02", - id: 2, - node_id: "T", - slug: "algodale01", - description: "스터디", - privacy: "closed", - notification_setting: "notifications_enabled", - url: "some-url", - html_url: "some-url", - members_url: "members-url", - repositories_url: "repositories-url", - permission: "pull", - parent: null, - }, - { - name: "another-team", // filtered out - id: 3, - node_id: "T", - slug: "algodale01", - description: "스터디", - privacy: "closed", - notification_setting: "notifications_enabled", - url: "some-url", - html_url: "some-url", - members_url: "members-url", - repositories_url: "repositories-url", - permission: "pull", - parent: null, - }, // should be filtered out -]; - -export const mockGitHubMembers: GitHubMember[] = [ - { login: "algo", id: 1 }, - { login: "dale", id: 2 }, -].map((member) => ({ - ...member, - node_id: "M", - avatar_url: "avatar-url", - gravatar_id: "", - url: "some-url", - html_url: "some-url", - followers_url: "some-url", - following_url: "some-url", - gists_url: "some-url", - starred_url: "some-url", - subscriptions_url: "some-url", - organizations_url: "some-url", - repos_url: "some-url", - events_url: "some-url", - received_events_url: "some-url", - type: "User", - user_view_type: "public", - site_admin: false, -})); - -export const mockGitHubTree: GitHubTree[] = [ - { - path: "problem1/algo.js", - type: "blob", - mode: "100644", - sha: "2", - size: 0, - url: "some-url", - }, - { - path: "problem1/dale.py", - type: "blob", - mode: "100644", - sha: "2", - size: 0, - url: "some-url", - }, - { - path: "problem2/algo.ts", - type: "blob", - mode: "100644", - sha: "2", - size: 0, - url: "some-url", - }, - { - path: "invalid/path", - type: "blob", - mode: "100644", - sha: "2", - size: 0, - url: "some-url", - }, - { - path: "README.md", - type: "blob", - mode: "100644", - sha: "2", - size: 0, - url: "some-url", - }, // should be filtered out -]; - -export const mockMembers = mockGitHubMembers.map((member) => ({ - id: member.login.toLowerCase(), - name: member.login, - profileUrl: member.avatar_url, - cohort: 1, - totalSubmissions: 2, - submissions: [ - { memberId: member.login, problemTitle: "problem1", language: "js" }, - { memberId: member.login, problemTitle: "problem2", language: "ts" }, - ], - progress: 50, - grade: member.login === "algo" ? Grade.BIG_TREE : Grade.SPROUT, -})); - -export function createMockMemberIdentity( - customMember: Partial = {}, -): MemberIdentity { - return { - id: faker.internet.userName().toLowerCase(), - name: faker.internet.userName(), - cohort: faker.number.int({ min: 1, max: 10 }), - profileUrl: faker.internet.url(), - ...customMember, - }; -} - -export function createMockSubmission( - customSubmission: Partial = {}, -): Submission { - return { - memberId: faker.internet.userName(), - problemTitle: faker.word.words().replaceAll(" ", "-"), - language: faker.helpers.arrayElement(["js", "ts", "py"]), - ...customSubmission, - }; -} - -export function createMockSubmissions( - memberId: string, - count: number, -): Submission[] { - return faker.helpers.arrayElements(problems, count).map((problem) => - createMockSubmission({ - memberId, - problemTitle: problem.title, - }), - ); -} - -export function createMockMember(custom: Partial = {}): Member { - return { - id: faker.string.uuid(), - name: faker.person.fullName(), - cohort: faker.number.int({ min: 1, max: 10 }), - profileUrl: faker.internet.url(), - progress: faker.number.int({ min: 0, max: 100 }), - grade: faker.helpers.arrayElement(Object.values(Grade)), - solvedProblems: faker.helpers.arrayElements( - problems, - faker.number.int({ min: 0, max: 5 }), - ), - ...custom, - }; -} diff --git a/src/api/services/fetch/fetchService.test.ts b/src/api/services/fetch/fetchService.test.ts index 67eef4f..9c2dc84 100644 --- a/src/api/services/fetch/fetchService.test.ts +++ b/src/api/services/fetch/fetchService.test.ts @@ -1,13 +1,49 @@ import { beforeEach, expect, test, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; import { createGitHubClient } from "../../infra/gitHub/gitHubClient"; -import { - dummyConfig, - mockGitHubMembers, - mockGitHubTeams, - mockGitHubTree, -} from "../common/fixtures"; import { createFetchService } from "./fetchService"; - +import { Grade } from "../types"; +import { GitHubMember, GitHubTeam, GitHubTree } from "../../infra/gitHub/types"; + +// Mock data +const dummyConfig = { + totalProblemCount: 6, + gradeThresholds: [ + ["TREE", 5], + ["FRUIT", 4], + ["BRANCH", 3], + ["LEAF", 2], + ["SPROUT", 1], + ["SEED", 0], + ] as [Grade, number][], + gitHubToken: "test-token", +}; + +const mockGitHubMembers = Array.from({ length: 10 }, (_, idx) => ({ + ...mock(), + login: `member${idx}`, + id: idx, +})); + +const mockGitHubTeams = [ + { ...mock(), name: "leetcode1" }, + { ...mock(), name: "leetcode2" }, + { ...mock(), name: "another-team" }, +]; + +const mockGitHubTrees: GitHubTree[] = [ + "problem1/algo.js", + "problem1/dale.py", + "problem2/algo.ts", + "invalid/path", + "README.md", +].map((path) => ({ + ...mock(), + type: "blob", + path, +})); + +// Mock services const mockGetTeamNames = vi.fn(); const mockGetTeamMembers = vi.fn(); const mockGetDirectoryTree = vi.fn(); @@ -36,7 +72,7 @@ test("fetchMembers should fetch and transform members correctly", async () => { const result = await fetchService.fetchMembers(); // Assert - expect(mockGetTeamNames).toHaveBeenCalledWith(dummyConfig.study.organization); + expect(mockGetTeamNames).toHaveBeenCalledWith("DaleStudy"); expect(mockGetTeamMembers).toHaveBeenCalledTimes(2); // Only algodale teams expect(result).toEqual( mockGitHubMembers.map((member) => ({ @@ -108,16 +144,16 @@ test("fetchMembers should handle duplicate members keeping the latest cohort", a test("fetchSubmissions should fetch and parse submissions correctly", async () => { // Arrange - mockGetDirectoryTree.mockResolvedValue(mockGitHubTree); + mockGetDirectoryTree.mockResolvedValue(mockGitHubTrees); // Act const result = await fetchService.fetchSubmissions("test-repo"); // Assert expect(mockGetDirectoryTree).toHaveBeenCalledWith( - dummyConfig.study.organization, + "DaleStudy", "test-repo", - dummyConfig.study.branchName, + "main", ); expect(result).toEqual([ @@ -142,7 +178,7 @@ test("fetchSubmissions should fetch and parse submissions correctly", async () = test("fetchSubmissions should filter out invalid submission paths", async () => { // Arrange const treeWithInvalidPaths = [ - ...mockGitHubTree, + ...mockGitHubTrees, { path: "invalid/path/format", type: "blob", @@ -170,7 +206,7 @@ test("fetchSubmissions should filter out invalid submission paths", async () => test("fetchSubmissions should filter out non-submission files", async () => { // Arrange mockGetDirectoryTree.mockResolvedValue([ - ...mockGitHubTree, + ...mockGitHubTrees, { path: "README.md", type: "blob", diff --git a/src/api/services/fetch/fetchService.ts b/src/api/services/fetch/fetchService.ts index 2736191..0b169a8 100644 --- a/src/api/services/fetch/fetchService.ts +++ b/src/api/services/fetch/fetchService.ts @@ -1,26 +1,25 @@ import type { Config } from "../../config/types"; import { createGitHubClient } from "../../infra/gitHub/gitHubClient"; import type { GitHubTree } from "../../infra/gitHub/types"; -import type { Cohort, MemberIdentity, Submission } from "../common/types"; +import type { Cohort, MemberIdentity, Submission } from "../types"; export function createFetchService(config: Config) { - const gitHubClient = createGitHubClient(config.gitHub); + const gitHubClient = createGitHubClient(config.gitHubToken); return { fetchMembers: async (): Promise => { - const teamNames = await gitHubClient.getTeamNames( - config.study.organization, - ); + const teamPrefix = "leetcode"; + const teamNames = await gitHubClient.getTeamNames("DaleStudy"); const members = await Promise.all( teamNames - .filter((name) => name.startsWith(config.study.teamPrefix)) + .filter((name) => name.startsWith(teamPrefix)) .map(async (teamName) => { const members = await gitHubClient.getTeamMembers( - config.study.organization, + "DaleStudy", teamName, ); - const cohort = parseCohort(teamName, config.study.teamPrefix); + const cohort = parseCohort(teamName, teamPrefix); return members.map( (member): MemberIdentity => ({ @@ -38,9 +37,9 @@ export function createFetchService(config: Config) { fetchSubmissions: async (repoName: string): Promise => { const submissions = await gitHubClient.getDirectoryTree( - config.study.organization, + "DaleStudy", repoName, - config.study.branchName, + "main", ); return submissions diff --git a/src/api/services/process/processService.test.ts b/src/api/services/process/processService.test.ts index f844fb5..248ce5f 100644 --- a/src/api/services/process/processService.test.ts +++ b/src/api/services/process/processService.test.ts @@ -1,18 +1,27 @@ import { faker } from "@faker-js/faker"; import { expect, test } from "vitest"; +import { mock } from "vitest-mock-extended"; import { problems } from "../../../constants/problems"; import type { Config } from "../../config/types"; -import { Grade } from "../common/types"; +import { Grade, MemberIdentity, Submission } from "../types"; import { createProcessService } from "./processService"; -import { - createMockMemberIdentity, - createMockSubmission, - createMockSubmissions, - dummyConfig, - dummyStudyConfig, -} from "../common/fixtures"; +// Mock data +const dummyConfig = { + totalProblemCount: 6, + gradeThresholds: [ + ["TREE", 5], + ["FRUIT", 4], + ["BRANCH", 3], + ["LEAF", 2], + ["SPROUT", 1], + ["SEED", 0], + ] as [Grade, number][], + gitHubToken: "test-token", +}; +const mockMemberIdentity = mock(); +const mockSubmission = mock(); const createMockProcessService = (customConfig: Partial = {}) => { return createProcessService({ @@ -26,22 +35,32 @@ test("calculate submissions and progress", () => { const totalProblemCount = 75; const totalSubmissions = faker.number.int({ min: 1, max: 75 }); const processService = createMockProcessService({ - study: { - ...dummyStudyConfig, - totalProblemCount, - }, + totalProblemCount, }); - const member = createMockMemberIdentity(); - const targetSubmissions = createMockSubmissions(member.id, totalSubmissions); - const dummySubmissions = createMockSubmissions( - "dummyMemberId", - faker.number.int({ min: 1, max: 10 }), + const member = mockMemberIdentity; + const targetSubmissions = Array.from( + { length: totalSubmissions }, + (_, idx) => ({ + ...mockSubmission, + memberId: member.id, + problemTitle: problems[idx].title, + language: faker.helpers.arrayElement(["js", "ts", "py"]), + }), + ); + + const submissionsOfOtherMember = Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, idx) => ({ + ...mockSubmission, + memberId: "OtherMemberId", + problemTitle: problems[idx].title, + }), ); // Act const members = processService.getMembers( - [member, createMockMemberIdentity()], - [...targetSubmissions, ...dummySubmissions], + [member, mockMemberIdentity], + [...targetSubmissions, ...submissionsOfOtherMember], ); const { progress } = members.find((member) => member.id === member.id)!; @@ -54,17 +73,19 @@ test("calculate submissions and progress", () => { test("remove duplicate problem submissions", () => { // Arrange const processService = createMockProcessService(); - const memberIdentity = createMockMemberIdentity(); + const memberIdentity = mockMemberIdentity; const sameProblemTitle = faker.helpers.arrayElement(problems).title; const duplicateSubmissions = [ - createMockSubmission({ + { + ...mockSubmission, memberId: memberIdentity.id, problemTitle: sameProblemTitle, - }), - createMockSubmission({ + }, + { + ...mockSubmission, memberId: memberIdentity.id, problemTitle: sameProblemTitle, - }), + }, ]; // Act @@ -77,33 +98,51 @@ test("remove duplicate problem submissions", () => { expect(solvedProblems.length).toBe(1); }); +test("remove member with no submissions", () => { + // Arrange + const processService = createMockProcessService(); + const memberIdentity = mockMemberIdentity; // no submissions + + // Act + const members = processService.getMembers([memberIdentity], []); + + // Assert + expect(members).toHaveLength(0); +}); + test.each([ - [0, Grade.SEED], [1, Grade.SEED], [2, Grade.SPROUT], [3, Grade.SPROUT], - [4, Grade.SMALL_TREE], - [5, Grade.SMALL_TREE], - [6, Grade.BIG_TREE], - [7, Grade.BIG_TREE], + [4, Grade.LEAF], + [5, Grade.LEAF], + [6, Grade.BRANCH], + [7, Grade.BRANCH], + [8, Grade.FRUIT], + [9, Grade.FRUIT], + [10, Grade.TREE], + [11, Grade.TREE], ])( "assign grades based on submissions: totalSubmissions: %i, expectedGrade: %s", (totalSubmissions, expectedGrade) => { // Arrange const config: Partial = { - study: { - ...dummyStudyConfig, - gradeThresholds: [ - [Grade.SEED, 0], - [Grade.SPROUT, 2], - [Grade.SMALL_TREE, 4], - [Grade.BIG_TREE, 6], - ], - }, + gradeThresholds: [ + [Grade.SEED, 0], + [Grade.SPROUT, 2], + [Grade.LEAF, 4], + [Grade.BRANCH, 6], + [Grade.FRUIT, 8], + [Grade.TREE, 10], + ], }; const processService = createMockProcessService(config); - const member = createMockMemberIdentity(); - const submissions = createMockSubmissions(member.id, totalSubmissions); + const member = mockMemberIdentity; + const submissions = Array.from({ length: totalSubmissions }, (_, idx) => ({ + ...mockSubmission, + memberId: member.id, + problemTitle: problems[idx].title, + })); // Act const { grade } = processService.getMembers([member], submissions)[0]; diff --git a/src/api/services/process/processService.ts b/src/api/services/process/processService.ts index 4d25a80..5cea736 100644 --- a/src/api/services/process/processService.ts +++ b/src/api/services/process/processService.ts @@ -5,7 +5,7 @@ import { type Member, type MemberIdentity, type Submission, -} from "../common/types"; +} from "../types"; export function createProcessService(config: Config) { return { @@ -16,8 +16,9 @@ export function createProcessService(config: Config) { const memberMap = initializeMemberMap(memberIdentities); updateSubmissions(memberMap, submissions); - calculateProgress(memberMap, config.study.totalProblemCount); - updateGrades(memberMap, config.study.gradeThresholds); + dropMembersWithoutSubmissions(memberMap); + calculateProgress(memberMap, config.totalProblemCount); + updateGrades(memberMap, config.gradeThresholds); return Object.values(memberMap); }, @@ -64,6 +65,16 @@ const updateSubmissions = ( }); }; +const dropMembersWithoutSubmissions = ( + memberMap: Record, +): void => { + Object.keys(memberMap).forEach((memberId) => { + if (memberMap[memberId].solvedProblems.length === 0) { + delete memberMap[memberId]; + } + }); +}; + const calculateProgress = ( memberMap: Record, totalProblemCount: number, diff --git a/src/api/services/common/types.ts b/src/api/services/types.ts similarity index 90% rename from src/api/services/common/types.ts rename to src/api/services/types.ts index 0fd4911..2e973d2 100644 --- a/src/api/services/common/types.ts +++ b/src/api/services/types.ts @@ -3,8 +3,10 @@ export type Cohort = number; export enum Grade { SEED = "SEED", SPROUT = "SPROUT", - SMALL_TREE = "SMALL_TREE", - BIG_TREE = "BIG_TREE", + LEAF = "LEAF", + BRANCH = "BRANCH", + FRUIT = "FRUIT", + TREE = "TREE", } export type MemberIdentity = { diff --git a/src/components/Card/Card.stories.tsx b/src/components/Card/Card.stories.tsx index 2828ddd..0bae2be 100644 --- a/src/components/Card/Card.stories.tsx +++ b/src/components/Card/Card.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import Card from "./Card"; -import { Grade } from "../../api/services/common/types"; +import { Grade } from "../../api/services/types"; const meta: Meta = { component: Card, diff --git a/src/components/Card/Card.test.tsx b/src/components/Card/Card.test.tsx index ed976c9..428d8c6 100644 --- a/src/components/Card/Card.test.tsx +++ b/src/components/Card/Card.test.tsx @@ -1,30 +1,28 @@ import { render, screen, within } from "@testing-library/react"; import { expect, test } from "vitest"; -import { Grade } from "../../api/services/common/types"; +import { Grade } from "../../api/services/types"; import Card from "./Card"; test("render grade image", () => { - render(); + render(); expect( - screen.getByRole("img", { name: `${Grade.BIG_TREE} image` }), + screen.getByRole("img", { name: `${Grade.TREE} image` }), ).toBeInTheDocument(); }); test("render github name", () => { const name = "user123"; - render(); + render(); expect(screen.getByRole("region", { name })).toBeInTheDocument(); }); test("render cohort", () => { const cohort = 2; - render( - , - ); + render(); expect( screen.getByRole("region", { name: `${cohort}기` }), @@ -33,7 +31,7 @@ test("render cohort", () => { test("render progress link", () => { const id = "test"; - render(); + render(); const link = within( screen.getByRole("region", { name: `card-navigation-${id}` }), @@ -45,7 +43,7 @@ test("render progress link", () => { test("render certificate link", () => { const id = "test"; - render(); + render(); const link = within( screen.getByRole("region", { name: `card-navigation-${id}` }), diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index bd8b545..26dc746 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -2,7 +2,7 @@ import Seed from "../../assets/Seed.png"; import Sprout from "../../assets/Sprout.png"; import YoungTree from "../../assets/YoungTree.png"; import LargeTree from "../../assets/LargeTree.png"; -import type { Grade } from "../../api"; +import { Grade } from "../../api/services/types"; import Link from "../Link/Link"; import styles from "./Card.module.css"; @@ -17,8 +17,10 @@ interface CardProps { const imageTable = { SEED: Seed, SPROUT: Sprout, - SMALL_TREE: YoungTree, - BIG_TREE: LargeTree, + LEAF: Sprout, + BRANCH: Sprout, + FRUIT: YoungTree, + TREE: LargeTree, }; export default function Card({ id, name, cohort, grade }: CardProps) { diff --git a/src/components/Certificate/Certificate.tsx b/src/components/Certificate/Certificate.tsx index e25b392..973eef0 100644 --- a/src/components/Certificate/Certificate.tsx +++ b/src/components/Certificate/Certificate.tsx @@ -1,4 +1,4 @@ -import { getMembers } from "../../api/services/store/storeService"; +import { getMembers } from "../../api/getMembers"; import useMembers from "../../hooks/useMembers"; import Footer from "../Footer/Footer"; diff --git a/src/components/Leaderboard/Leaderboard.test.tsx b/src/components/Leaderboard/Leaderboard.test.tsx index 50992d4..7e4e0b7 100644 --- a/src/components/Leaderboard/Leaderboard.test.tsx +++ b/src/components/Leaderboard/Leaderboard.test.tsx @@ -1,9 +1,9 @@ import { faker } from "@faker-js/faker"; -import { render, screen } from "@testing-library/react"; import { expect, test, vi } from "vitest"; import { mock } from "vitest-mock-extended"; +import { render, screen } from "@testing-library/react"; -import type { Member } from "../../api/services/common/types"; +import type { Member } from "../../api/services/types"; import useMembers from "../../hooks/useMembers"; import Leaderboard from "./Leaderboard"; diff --git a/src/components/Leaderboard/Leaderboard.tsx b/src/components/Leaderboard/Leaderboard.tsx index 08459d6..6499dc8 100644 --- a/src/components/Leaderboard/Leaderboard.tsx +++ b/src/components/Leaderboard/Leaderboard.tsx @@ -2,8 +2,9 @@ import Footer from "../Footer/Footer"; import Header from "../Header/Header"; import SearchBar from "../SearchBar/SearchBar"; -import { getMembers } from "../../api/services/store/storeService"; +import { getMembers } from "../../api/getMembers"; import useMembers, { type Filter } from "../../hooks/useMembers"; + import Card from "../Card/Card"; import styles from "./Leaderboard.module.css"; diff --git a/src/components/Progress/Progress.tsx b/src/components/Progress/Progress.tsx index 62e236c..8bb0146 100644 --- a/src/components/Progress/Progress.tsx +++ b/src/components/Progress/Progress.tsx @@ -2,7 +2,7 @@ import Footer from "../Footer/Footer"; import Header from "../Header/Header"; import Table from "../Table/Table"; -import { getMembers } from "../../api/services/store/storeService"; +import { getMembers } from "../../api/getMembers"; import useMembers from "../../hooks/useMembers"; import styles from "./Progress.module.css"; diff --git a/src/constants/problems.ts b/src/constants/problems.ts index 46ce487..05ae3b3 100644 --- a/src/constants/problems.ts +++ b/src/constants/problems.ts @@ -1,4 +1,4 @@ -import type { Problem } from "../api/services/common/types"; +import type { Problem } from "../api/services/types"; export const problems: Problem[] = [ { title: "3sum", difficulty: "medium" }, diff --git a/src/hooks/useMembers.test.ts b/src/hooks/useMembers.test.ts index abb02db..99f12b2 100644 --- a/src/hooks/useMembers.test.ts +++ b/src/hooks/useMembers.test.ts @@ -1,9 +1,26 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { expect, test, vi } from "vitest"; +import { faker } from "@faker-js/faker"; -import { createMockMember } from "../api/services/common/fixtures"; -import { type Member } from "../api/services/common/types"; +import { Grade, type Member } from "../api/services/types"; import useMembers from "./useMembers"; +import { problems } from "../constants/problems"; + +function createMockMember(custom: Partial = {}): Member { + return { + id: faker.string.uuid(), + name: faker.person.fullName(), + cohort: faker.number.int({ min: 1, max: 10 }), + profileUrl: faker.internet.url(), + progress: faker.number.int({ min: 0, max: 100 }), + grade: faker.helpers.arrayElement(Object.values(Grade)), + solvedProblems: faker.helpers.arrayElements( + problems, + faker.number.int({ min: 0, max: 5 }), + ), + ...custom, + }; +} test("fetch member info successfully and update state", async () => { const expectedMembers: Member[] = [ diff --git a/src/hooks/useMembers.ts b/src/hooks/useMembers.ts index 9382f54..74bea82 100644 --- a/src/hooks/useMembers.ts +++ b/src/hooks/useMembers.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import type { Member } from "../api/services/common/types"; +import type { Member } from "../api/services/types"; type UseMembers = (params: { getMembers: () => Promise }) => { members: Member[];