diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f27b1fda07b5..63eedac36d195 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: pnpm run build:frontend - name: Run tests - run: pnpm run test --silent + run: pnpm run test frontend-test-e2e: name: Frontend E2E test diff --git a/apps/backend/tests/api.test.js b/apps/backend/tests/api.test.js index e086abb524339..ec5d7ca172810 100644 --- a/apps/backend/tests/api.test.js +++ b/apps/backend/tests/api.test.js @@ -1,26 +1,27 @@ // @ts-check +import { api, getConfig } from "@stats-organization/github-readme-stats-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ - api: vi.fn(), - storeRequest: vi.fn(), - getUserAccessByName: vi.fn(), - config: {}, -})); +import router from "../router.js"; +import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; +import { getUserAccessByName, storeRequest } from "../src/common/database.js"; -vi.mock("@stats-organization/github-readme-stats-core", async () => { +vi.mock(import("@stats-organization/github-readme-stats-core"), async () => { const { mockCore } = await import("./utils.js"); - return mockCore({ api: mocks.api, getConfig: () => mocks.config }); + return mockCore(); }); -vi.mock("../src/common/database.js", () => ({ - storeRequest: mocks.storeRequest, - getUserAccessByName: mocks.getUserAccessByName, +vi.mock(import("../src/common/database.js"), async (importOriginal) => ({ + ...(await importOriginal()), + storeRequest: vi.fn(), + getUserAccessByName: vi.fn(), })); -import router from "../router.js"; -import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; +const apiMock = vi.mocked(api); +const getConfigMock = vi.mocked(getConfig); +const storeRequestMock = vi.mocked(storeRequest); +const getUserAccessByNameMock = vi.mocked(getUserAccessByName); const createRequest = (search = "") => ({ headers: {}, @@ -43,18 +44,18 @@ const errorCacheHeader = `stale-while-revalidate=${DURATIONS.ONE_DAY}`; beforeEach(() => { - mocks.api.mockReset(); - mocks.storeRequest.mockReset().mockResolvedValue(undefined); - mocks.getUserAccessByName.mockReset().mockResolvedValue(null); - mocks.config = {}; + apiMock.mockReset(); + getConfigMock.mockReset().mockReturnValue({}); + storeRequestMock.mockReset().mockResolvedValue(undefined); + getUserAccessByNameMock.mockReset().mockResolvedValue(null); // CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally delete process.env.CACHE_SECONDS; }); describe("Test /api backend routing", () => { it("happy path should pass query params and user PAT, respond with stats content and persist request", async () => { - mocks.getUserAccessByName.mockResolvedValue({ token: "user-pat" }); - mocks.api.mockResolvedValue({ + getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" }); + apiMock.mockResolvedValue({ status: "success", content: "mock-stats-svg", }); @@ -66,8 +67,8 @@ describe("Test /api backend routing", () => { await router(req, res); - expect(mocks.getUserAccessByName).toHaveBeenCalledWith("anuraghazra"); - expect(mocks.api).toHaveBeenCalledWith( + expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra"); + expect(apiMock).toHaveBeenCalledWith( { username: "anuraghazra", theme: "dark", @@ -85,11 +86,11 @@ describe("Test /api backend routing", () => { ["Content-Type", "image/svg+xml"], ]); expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-stats-svg"); - expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req); + expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req); }); it("should use the shorter error cache for temporary stats errors", async () => { - mocks.api.mockResolvedValue({ + apiMock.mockResolvedValue({ status: "error - temporary", content: "temporary-error-svg", }); @@ -99,8 +100,8 @@ describe("Test /api backend routing", () => { await router(req, res); - expect(mocks.getUserAccessByName).toHaveBeenCalledWith("anuraghazra"); - expect(mocks.api).toHaveBeenCalledWith( + expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra"); + expect(apiMock).toHaveBeenCalledWith( { username: "anuraghazra", }, @@ -111,11 +112,11 @@ describe("Test /api backend routing", () => { ["Content-Type", "image/svg+xml"], ]); expect(res.end).toHaveBeenCalledExactlyOnceWith("temporary-error-svg"); - expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req); + expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req); }); it("should not persist permanent stats errors returned by core", async () => { - mocks.api.mockResolvedValue({ + apiMock.mockResolvedValue({ status: "error - permanent", content: "permanent-error-svg", }); @@ -125,8 +126,8 @@ describe("Test /api backend routing", () => { await router(req, res); - expect(mocks.getUserAccessByName).toHaveBeenCalledWith("anuraghazra"); - expect(mocks.api).toHaveBeenCalledWith( + expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra"); + expect(apiMock).toHaveBeenCalledWith( { username: "anuraghazra", }, @@ -137,7 +138,7 @@ describe("Test /api backend routing", () => { ["Content-Type", "image/svg+xml"], ]); expect(res.end).toHaveBeenCalledExactlyOnceWith("permanent-error-svg"); - expect(mocks.storeRequest).not.toHaveBeenCalled(); + expect(storeRequestMock).not.toHaveBeenCalled(); }); it("should reject blacklisted usernames before calling core logic", async () => { @@ -146,8 +147,8 @@ describe("Test /api backend routing", () => { await router(req, res); - expect(mocks.api).not.toHaveBeenCalled(); - expect(mocks.getUserAccessByName).not.toHaveBeenCalled(); + expect(apiMock).not.toHaveBeenCalled(); + expect(getUserAccessByNameMock).not.toHaveBeenCalled(); expect(res.setHeader.mock.calls).toEqual([ ["Cache-Control", defaultCacheHeader], ["Content-Type", "image/svg+xml"], @@ -155,21 +156,19 @@ describe("Test /api backend routing", () => { expect(res.end).toHaveBeenCalledExactlyOnceWith( "render-error:This username is blacklisted", ); - expect(mocks.storeRequest).not.toHaveBeenCalled(); + expect(storeRequestMock).not.toHaveBeenCalled(); }); it("should reject non-whitelisted usernames before calling core logic", async () => { - mocks.config = { - whitelist: ["allowed-user"], - }; + getConfigMock.mockReturnValue({ whitelist: ["allowed-user"] }); const req = createRequest("username=blocked-user"); const res = createResponse(); await router(req, res); - expect(mocks.api).not.toHaveBeenCalled(); - expect(mocks.getUserAccessByName).not.toHaveBeenCalled(); + expect(apiMock).not.toHaveBeenCalled(); + expect(getUserAccessByNameMock).not.toHaveBeenCalled(); expect(res.setHeader.mock.calls).toEqual([ ["Cache-Control", defaultCacheHeader], ["Content-Type", "image/svg+xml"], @@ -177,6 +176,6 @@ describe("Test /api backend routing", () => { expect(res.end).toHaveBeenCalledExactlyOnceWith( "render-error:This username is not whitelisted", ); - expect(mocks.storeRequest).not.toHaveBeenCalled(); + expect(storeRequestMock).not.toHaveBeenCalled(); }); }); diff --git a/apps/backend/tests/gist.test.js b/apps/backend/tests/gist.test.js index 8ee563791e66f..564a733d5dad4 100644 --- a/apps/backend/tests/gist.test.js +++ b/apps/backend/tests/gist.test.js @@ -1,26 +1,27 @@ // @ts-check +import { getConfig, gist } from "@stats-organization/github-readme-stats-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ - gist: vi.fn(), - storeRequest: vi.fn(), - getUserAccessByName: vi.fn(), - config: {}, -})); +import router from "../router.js"; +import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; +import { getUserAccessByName, storeRequest } from "../src/common/database.js"; -vi.mock("@stats-organization/github-readme-stats-core", async () => { +vi.mock(import("@stats-organization/github-readme-stats-core"), async () => { const { mockCore } = await import("./utils.js"); - return mockCore({ gist: mocks.gist, getConfig: () => mocks.config }); + return mockCore(); }); -vi.mock("../src/common/database.js", () => ({ - storeRequest: mocks.storeRequest, - getUserAccessByName: mocks.getUserAccessByName, +vi.mock(import("../src/common/database.js"), async (importOriginal) => ({ + ...(await importOriginal()), + storeRequest: vi.fn(), + getUserAccessByName: vi.fn(), })); -import router from "../router.js"; -import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; +const gistMock = vi.mocked(gist); +const getConfigMock = vi.mocked(getConfig); +const storeRequestMock = vi.mocked(storeRequest); +const getUserAccessByNameMock = vi.mocked(getUserAccessByName); const createRequest = (search) => ({ headers: {}, @@ -43,17 +44,17 @@ const errorCacheHeader = `stale-while-revalidate=${DURATIONS.ONE_DAY}`; beforeEach(() => { - mocks.gist.mockReset(); - mocks.storeRequest.mockReset().mockResolvedValue(undefined); - mocks.getUserAccessByName.mockReset().mockResolvedValue(null); - mocks.config = {}; + gistMock.mockReset(); + getConfigMock.mockReset().mockReturnValue({}); + storeRequestMock.mockReset().mockResolvedValue(undefined); + getUserAccessByNameMock.mockReset().mockResolvedValue(null); // CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally delete process.env.CACHE_SECONDS; }); describe("Test /api/gist backend routing", () => { it("happy path should pass query params, respond with gist content and persist request", async () => { - mocks.gist.mockResolvedValue({ + gistMock.mockResolvedValue({ status: "success", content: "mock-gist-svg", }); @@ -63,11 +64,11 @@ describe("Test /api/gist backend routing", () => { await router(req, res); - expect(mocks.gist).toHaveBeenCalledWith({ + expect(gistMock).toHaveBeenCalledWith({ id: "bbfce31e0217a3689c8d961a356cb10d", theme: "dark", }); - expect(mocks.getUserAccessByName).not.toHaveBeenCalled(); + expect(getUserAccessByNameMock).not.toHaveBeenCalled(); expect(req.query).toEqual({ id: "bbfce31e0217a3689c8d961a356cb10d", theme: "dark", @@ -77,11 +78,11 @@ describe("Test /api/gist backend routing", () => { ["Content-Type", "image/svg+xml"], ]); expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-gist-svg"); - expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req); + expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req); }); it("should use the shorter error cache for temporary gist errors", async () => { - mocks.gist.mockResolvedValue({ + gistMock.mockResolvedValue({ status: "error - temporary", content: "temporary-error-svg", }); @@ -91,20 +92,20 @@ describe("Test /api/gist backend routing", () => { await router(req, res); - expect(mocks.gist).toHaveBeenCalledWith({ + expect(gistMock).toHaveBeenCalledWith({ id: "bbfce31e0217a3689c8d961a356cb10d", }); - expect(mocks.getUserAccessByName).not.toHaveBeenCalled(); + expect(getUserAccessByNameMock).not.toHaveBeenCalled(); expect(res.setHeader.mock.calls).toEqual([ ["Cache-Control", errorCacheHeader], ["Content-Type", "image/svg+xml"], ]); expect(res.end).toHaveBeenCalledExactlyOnceWith("temporary-error-svg"); - expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req); + expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req); }); it("should not persist permanent gist errors returned by core", async () => { - mocks.gist.mockResolvedValue({ + gistMock.mockResolvedValue({ status: "error - permanent", content: "permanent-error-svg", }); @@ -114,30 +115,28 @@ describe("Test /api/gist backend routing", () => { await router(req, res); - expect(mocks.gist).toHaveBeenCalledWith({ + expect(gistMock).toHaveBeenCalledWith({ id: "bbfce31e0217a3689c8d961a356cb10d", }); - expect(mocks.getUserAccessByName).not.toHaveBeenCalled(); + expect(getUserAccessByNameMock).not.toHaveBeenCalled(); expect(res.setHeader.mock.calls).toEqual([ ["Cache-Control", defaultCacheHeader], ["Content-Type", "image/svg+xml"], ]); expect(res.end).toHaveBeenCalledExactlyOnceWith("permanent-error-svg"); - expect(mocks.storeRequest).not.toHaveBeenCalled(); + expect(storeRequestMock).not.toHaveBeenCalled(); }); it("should reject non-whitelisted gist ids before calling core logic", async () => { - mocks.config = { - gistWhitelist: ["allowed-gist-id"], - }; + getConfigMock.mockReturnValue({ gistWhitelist: ["allowed-gist-id"] }); const req = createRequest("id=blocked-gist-id"); const res = createResponse(); await router(req, res); - expect(mocks.gist).not.toHaveBeenCalled(); - expect(mocks.getUserAccessByName).not.toHaveBeenCalled(); + expect(gistMock).not.toHaveBeenCalled(); + expect(getUserAccessByNameMock).not.toHaveBeenCalled(); expect(res.setHeader.mock.calls).toEqual([ ["Cache-Control", defaultCacheHeader], ["Content-Type", "image/svg+xml"], @@ -145,6 +144,6 @@ describe("Test /api/gist backend routing", () => { expect(res.end).toHaveBeenCalledExactlyOnceWith( "render-error:This gist ID is not whitelisted", ); - expect(mocks.storeRequest).not.toHaveBeenCalled(); + expect(storeRequestMock).not.toHaveBeenCalled(); }); }); diff --git a/apps/backend/tests/pat-info.test.js b/apps/backend/tests/pat-info.test.js index ef01b9bae4233..36665116dc1d7 100644 --- a/apps/backend/tests/pat-info.test.js +++ b/apps/backend/tests/pat-info.test.js @@ -4,7 +4,15 @@ import axios from "axios"; import MockAdapter from "axios-mock-adapter"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; const mock = new MockAdapter(axios); @@ -63,6 +71,11 @@ beforeAll(async () => { vi.stubEnv("PAT_3", "testPAT3"); vi.stubEnv("PAT_4", "testPAT4"); + const { logger } = + await import("@stats-organization/github-readme-stats-core"); + vi.spyOn(logger, "log").mockImplementation(() => {}); + vi.spyOn(logger, "error").mockImplementation(() => {}); + ({ RATE_LIMIT_SECONDS, default: patInfo } = await import("../api-renamed/status/pat-info.js")); }); @@ -74,6 +87,10 @@ afterEach(() => { vi.resetModules(); }); +afterAll(() => { + vi.restoreAllMocks(); +}); + describe("Test /api/status/pat-info", () => { it("should return only 'validPATs' if all PATs are valid", async () => { mock diff --git a/apps/backend/tests/pin.test.js b/apps/backend/tests/pin.test.js index 628e68fa07fd9..90eb6b8a141a6 100644 --- a/apps/backend/tests/pin.test.js +++ b/apps/backend/tests/pin.test.js @@ -1,25 +1,26 @@ // @ts-check +import { pin } from "@stats-organization/github-readme-stats-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ - pin: vi.fn(), - storeRequest: vi.fn(), - getUserAccessByName: vi.fn(), -})); +import router from "../router.js"; +import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; +import { getUserAccessByName, storeRequest } from "../src/common/database.js"; -vi.mock("@stats-organization/github-readme-stats-core", async () => { +vi.mock(import("@stats-organization/github-readme-stats-core"), async () => { const { mockCore } = await import("./utils.js"); - return mockCore({ pin: mocks.pin }); + return mockCore(); }); -vi.mock("../src/common/database.js", () => ({ - storeRequest: mocks.storeRequest, - getUserAccessByName: mocks.getUserAccessByName, +vi.mock(import("../src/common/database.js"), async (importOriginal) => ({ + ...(await importOriginal()), + storeRequest: vi.fn(), + getUserAccessByName: vi.fn(), })); -import router from "../router.js"; -import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; +const pinMock = vi.mocked(pin); +const storeRequestMock = vi.mocked(storeRequest); +const getUserAccessByNameMock = vi.mocked(getUserAccessByName); const createRequest = (search = "") => ({ headers: {}, @@ -37,17 +38,17 @@ const defaultCacheHeader = `stale-while-revalidate=${DURATIONS.ONE_DAY}`; beforeEach(() => { - mocks.pin.mockReset(); - mocks.storeRequest.mockReset().mockResolvedValue(undefined); - mocks.getUserAccessByName.mockReset().mockResolvedValue(null); + pinMock.mockReset(); + storeRequestMock.mockReset().mockResolvedValue(undefined); + getUserAccessByNameMock.mockReset().mockResolvedValue(null); // CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally delete process.env.CACHE_SECONDS; }); describe("Test /api/pin backend routing", () => { it("happy path should pass query params and user PAT, respond with pin content and persist request", async () => { - mocks.getUserAccessByName.mockResolvedValue({ token: "user-pat" }); - mocks.pin.mockResolvedValue({ + getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" }); + pinMock.mockResolvedValue({ status: "success", content: "mock-pin-svg", }); @@ -59,8 +60,8 @@ describe("Test /api/pin backend routing", () => { await router(req, res); - expect(mocks.getUserAccessByName).toHaveBeenCalledWith("anuraghazra"); - expect(mocks.pin).toHaveBeenCalledWith( + expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra"); + expect(pinMock).toHaveBeenCalledWith( { username: "anuraghazra", repo: "convoychat", @@ -78,6 +79,6 @@ describe("Test /api/pin backend routing", () => { ["Content-Type", "image/svg+xml"], ]); expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-pin-svg"); - expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req); + expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req); }); }); diff --git a/apps/backend/tests/public-instance/api.test.js b/apps/backend/tests/public-instance/api.test.js index 506d9f05f09f8..34a8d98fbe381 100644 --- a/apps/backend/tests/public-instance/api.test.js +++ b/apps/backend/tests/public-instance/api.test.js @@ -1,8 +1,18 @@ // @ts-check +import { logger } from "@stats-organization/github-readme-stats-core"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { data_stats, normalizeSvg } from "../utils.js"; @@ -22,6 +32,11 @@ beforeEach(() => { mock.onPost("https://api.github.com/graphql").reply(200, data_stats); }); +beforeAll(() => { + vi.spyOn(logger, "log").mockImplementation(() => {}); + vi.spyOn(logger, "error").mockImplementation(() => {}); +}); + afterEach(() => { mock.reset(); vi.unstubAllEnvs(); @@ -29,6 +44,10 @@ afterEach(() => { vi.resetModules(); }); +afterAll(() => { + vi.restoreAllMocks(); +}); + describe("Test /api contract", () => { it("should match the public happy-path response snapshot", async () => { const { default: router } = await import("../../router.js"); diff --git a/apps/backend/tests/public-instance/gist.test.js b/apps/backend/tests/public-instance/gist.test.js index 0742ab3b00246..693617cf52e75 100644 --- a/apps/backend/tests/public-instance/gist.test.js +++ b/apps/backend/tests/public-instance/gist.test.js @@ -1,8 +1,18 @@ // @ts-check +import { logger } from "@stats-organization/github-readme-stats-core"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { happy_path_gist_data, normalizeSvg } from "../utils.js"; @@ -24,6 +34,11 @@ beforeEach(() => { .reply(200, happy_path_gist_data); }); +beforeAll(() => { + vi.spyOn(logger, "log").mockImplementation(() => {}); + vi.spyOn(logger, "error").mockImplementation(() => {}); +}); + afterEach(() => { mock.reset(); vi.unstubAllEnvs(); @@ -31,6 +46,10 @@ afterEach(() => { vi.resetModules(); }); +afterAll(() => { + vi.restoreAllMocks(); +}); + describe("Test /api/gist contract", () => { it("should match the public happy-path response snapshot", async () => { const { default: router } = await import("../../router.js"); diff --git a/apps/backend/tests/public-instance/pin.test.js b/apps/backend/tests/public-instance/pin.test.js index 12da77f939354..0c86dfe1a85af 100644 --- a/apps/backend/tests/public-instance/pin.test.js +++ b/apps/backend/tests/public-instance/pin.test.js @@ -1,8 +1,18 @@ // @ts-check +import { logger } from "@stats-organization/github-readme-stats-core"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { data_user, normalizeSvg } from "../utils.js"; @@ -22,6 +32,11 @@ beforeEach(() => { mock.onPost("https://api.github.com/graphql").reply(200, data_user); }); +beforeAll(() => { + vi.spyOn(logger, "log").mockImplementation(() => {}); + vi.spyOn(logger, "error").mockImplementation(() => {}); +}); + afterEach(() => { mock.reset(); vi.unstubAllEnvs(); @@ -29,6 +44,10 @@ afterEach(() => { vi.resetModules(); }); +afterAll(() => { + vi.restoreAllMocks(); +}); + describe("Test /api/pin contract", () => { it("should match the public happy-path response snapshot", async () => { const { default: router } = await import("../../router.js"); diff --git a/apps/backend/tests/public-instance/top-langs.test.js b/apps/backend/tests/public-instance/top-langs.test.js index 9eaba86cc4064..11739f2142f4f 100644 --- a/apps/backend/tests/public-instance/top-langs.test.js +++ b/apps/backend/tests/public-instance/top-langs.test.js @@ -1,8 +1,18 @@ // @ts-check +import { logger } from "@stats-organization/github-readme-stats-core"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { data_langs, normalizeSvg } from "../utils.js"; @@ -22,6 +32,11 @@ beforeEach(() => { mock.onPost("https://api.github.com/graphql").reply(200, data_langs); }); +beforeAll(() => { + vi.spyOn(logger, "log").mockImplementation(() => {}); + vi.spyOn(logger, "error").mockImplementation(() => {}); +}); + afterEach(() => { mock.reset(); vi.unstubAllEnvs(); @@ -29,6 +44,10 @@ afterEach(() => { vi.resetModules(); }); +afterAll(() => { + vi.restoreAllMocks(); +}); + describe("Test /api/top-langs contract", () => { it("should match the public happy-path response snapshot", async () => { const { default: router } = await import("../../router.js"); diff --git a/apps/backend/tests/public-instance/wakatime.test.js b/apps/backend/tests/public-instance/wakatime.test.js index 48a23bf83a9fd..aaa98bbd498fd 100644 --- a/apps/backend/tests/public-instance/wakatime.test.js +++ b/apps/backend/tests/public-instance/wakatime.test.js @@ -1,8 +1,18 @@ // @ts-check +import { logger } from "@stats-organization/github-readme-stats-core"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { normalizeSvg, wakaTimeData } from "../utils.js"; @@ -34,6 +44,11 @@ beforeEach(() => { .reply(200, wakaTimeData); }); +beforeAll(() => { + vi.spyOn(logger, "log").mockImplementation(() => {}); + vi.spyOn(logger, "error").mockImplementation(() => {}); +}); + afterEach(() => { mock.reset(); vi.unstubAllEnvs(); @@ -41,6 +56,10 @@ afterEach(() => { vi.resetModules(); }); +afterAll(() => { + vi.restoreAllMocks(); +}); + describe("Test /api/wakatime contract", () => { it("should match the public happy-path response snapshot", async () => { const { default: router } = await import("../../router.js"); diff --git a/apps/backend/tests/status.up.test.js b/apps/backend/tests/status.up.test.js index de3a5a7f05f15..02b9a28b29c93 100644 --- a/apps/backend/tests/status.up.test.js +++ b/apps/backend/tests/status.up.test.js @@ -2,9 +2,18 @@ * @file Tests for the status/up cloud function. */ +import { logger } from "@stats-organization/github-readme-stats-core"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; import up, { RATE_LIMIT_SECONDS } from "../api-renamed/status/up.js"; @@ -55,10 +64,19 @@ const shields_down = { color: "red", }; +beforeAll(() => { + vi.spyOn(logger, "log").mockImplementation(() => {}); + vi.spyOn(logger, "error").mockImplementation(() => {}); +}); + afterEach(() => { mock.reset(); }); +afterAll(() => { + vi.restoreAllMocks(); +}); + describe("Test /api/status/up", () => { it("should return `true` if request was successful", async () => { mock.onPost("https://api.github.com/graphql").replyOnce(200, successData); diff --git a/apps/backend/tests/top-langs.test.js b/apps/backend/tests/top-langs.test.js index 96d424e773561..eb7a3ebcd5c4d 100644 --- a/apps/backend/tests/top-langs.test.js +++ b/apps/backend/tests/top-langs.test.js @@ -1,25 +1,26 @@ // @ts-check +import { topLangs } from "@stats-organization/github-readme-stats-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ - topLangs: vi.fn(), - storeRequest: vi.fn(), - getUserAccessByName: vi.fn(), -})); +import router from "../router.js"; +import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; +import { getUserAccessByName, storeRequest } from "../src/common/database.js"; -vi.mock("@stats-organization/github-readme-stats-core", async () => { +vi.mock(import("@stats-organization/github-readme-stats-core"), async () => { const { mockCore } = await import("./utils.js"); - return mockCore({ topLangs: mocks.topLangs }); + return mockCore(); }); -vi.mock("../src/common/database.js", () => ({ - storeRequest: mocks.storeRequest, - getUserAccessByName: mocks.getUserAccessByName, +vi.mock(import("../src/common/database.js"), async (importOriginal) => ({ + ...(await importOriginal()), + storeRequest: vi.fn(), + getUserAccessByName: vi.fn(), })); -import router from "../router.js"; -import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; +const topLangsMock = vi.mocked(topLangs); +const storeRequestMock = vi.mocked(storeRequest); +const getUserAccessByNameMock = vi.mocked(getUserAccessByName); const createRequest = (search = "") => ({ headers: {}, @@ -37,17 +38,17 @@ const defaultCacheHeader = `stale-while-revalidate=${DURATIONS.ONE_DAY}`; beforeEach(() => { - mocks.topLangs.mockReset(); - mocks.storeRequest.mockReset().mockResolvedValue(undefined); - mocks.getUserAccessByName.mockReset().mockResolvedValue(null); + topLangsMock.mockReset(); + storeRequestMock.mockReset().mockResolvedValue(undefined); + getUserAccessByNameMock.mockReset().mockResolvedValue(null); // CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally delete process.env.CACHE_SECONDS; }); describe("Test /api/top-langs backend routing", () => { it("happy path should pass query params and user PAT, respond with top languages content and persist request", async () => { - mocks.getUserAccessByName.mockResolvedValue({ token: "user-pat" }); - mocks.topLangs.mockResolvedValue({ + getUserAccessByNameMock.mockResolvedValue({ token: "user-pat" }); + topLangsMock.mockResolvedValue({ status: "success", content: "mock-top-langs-svg", }); @@ -59,8 +60,8 @@ describe("Test /api/top-langs backend routing", () => { await router(req, res); - expect(mocks.getUserAccessByName).toHaveBeenCalledWith("anuraghazra"); - expect(mocks.topLangs).toHaveBeenCalledWith( + expect(getUserAccessByNameMock).toHaveBeenCalledWith("anuraghazra"); + expect(topLangsMock).toHaveBeenCalledWith( { username: "anuraghazra", layout: "compact", @@ -78,6 +79,6 @@ describe("Test /api/top-langs backend routing", () => { ["Content-Type", "image/svg+xml"], ]); expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-top-langs-svg"); - expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req); + expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req); }); }); diff --git a/apps/backend/tests/utils.js b/apps/backend/tests/utils.js index 9108685ace6cc..8602f932370b1 100644 --- a/apps/backend/tests/utils.js +++ b/apps/backend/tests/utils.js @@ -1,5 +1,7 @@ // @ts-check +import { vi } from "vitest"; + export const data_stats = { data: { user: { @@ -257,23 +259,32 @@ export const wakaTimeData = { }, }; +/** @typedef {import('@stats-organization/github-readme-stats-core')} CoreModule */ + /** * Creates a mock module for @stats-organization/github-readme-stats-core. - * @param {any} mocks Mocked functions of the core module. - * @returns {any} Mocked core module. + * @returns {CoreModule} Mocked core module. */ -export function mockCore(mocks) { - const noop = () => undefined; - +export function mockCore() { return { - api: mocks.api ?? noop, - gist: mocks.gist ?? noop, - pin: mocks.pin ?? noop, - topLangs: mocks.topLangs ?? noop, - wakatime: mocks.wakatime ?? noop, - getConfig: mocks.getConfig ?? (() => mocks.config ?? {}), + // @ts-expect-error no need to mock themes at the moment + themes: {}, + request: vi.fn(), + fetchWakatimeStats: vi.fn(), + retryer: vi.fn(), + dateDiff: vi.fn(), + api: vi.fn(), + gist: vi.fn(), + pin: vi.fn(), + topLangs: vi.fn(), + wakatime: vi.fn(), + getConfig: vi.fn().mockReturnValue({}), renderError: ({ message }) => `render-error:${message}`, clampValue: (value, min, max) => Math.min(Math.max(value, min), max), + logger: { + log: vi.fn(), + error: vi.fn(), + }, }; } diff --git a/apps/backend/tests/wakatime.test.js b/apps/backend/tests/wakatime.test.js index e1561724fa57d..0be8df96299d5 100644 --- a/apps/backend/tests/wakatime.test.js +++ b/apps/backend/tests/wakatime.test.js @@ -1,25 +1,26 @@ // @ts-check +import { wakatime } from "@stats-organization/github-readme-stats-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ - wakatime: vi.fn(), - storeRequest: vi.fn(), - getUserAccessByName: vi.fn(), -})); +import router from "../router.js"; +import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; +import { getUserAccessByName, storeRequest } from "../src/common/database.js"; -vi.mock("@stats-organization/github-readme-stats-core", async () => { +vi.mock(import("@stats-organization/github-readme-stats-core"), async () => { const { mockCore } = await import("./utils.js"); - return mockCore({ wakatime: mocks.wakatime }); + return mockCore(); }); -vi.mock("../src/common/database.js", () => ({ - storeRequest: mocks.storeRequest, - getUserAccessByName: mocks.getUserAccessByName, +vi.mock(import("../src/common/database.js"), async (importOriginal) => ({ + ...(await importOriginal()), + storeRequest: vi.fn(), + getUserAccessByName: vi.fn(), })); -import router from "../router.js"; -import { CACHE_TTL, DURATIONS } from "../src/common/cache.js"; +const wakatimeMock = vi.mocked(wakatime); +const storeRequestMock = vi.mocked(storeRequest); +const getUserAccessByNameMock = vi.mocked(getUserAccessByName); const createRequest = (search = "") => ({ headers: {}, @@ -37,16 +38,16 @@ const defaultCacheHeader = `stale-while-revalidate=${DURATIONS.ONE_DAY}`; beforeEach(() => { - mocks.wakatime.mockReset(); - mocks.storeRequest.mockReset().mockResolvedValue(undefined); - mocks.getUserAccessByName.mockReset().mockResolvedValue(null); + wakatimeMock.mockReset(); + storeRequestMock.mockReset().mockResolvedValue(undefined); + getUserAccessByNameMock.mockReset().mockResolvedValue(null); // CACHE_SECONDS is not set here, this is just to safeguard against CACHE_SECONDS being set externally delete process.env.CACHE_SECONDS; }); describe("Test /api/wakatime backend routing", () => { it("happy path should pass query params, respond with wakatime content and persist request", async () => { - mocks.wakatime.mockResolvedValue({ + wakatimeMock.mockResolvedValue({ status: "success", content: "mock-wakatime-svg", }); @@ -56,12 +57,12 @@ describe("Test /api/wakatime backend routing", () => { await router(req, res); - expect(mocks.wakatime).toHaveBeenCalledWith({ + expect(wakatimeMock).toHaveBeenCalledWith({ username: "anuraghazra", theme: "dark", layout: "compact", }); - expect(mocks.getUserAccessByName).not.toHaveBeenCalled(); + expect(getUserAccessByNameMock).not.toHaveBeenCalled(); expect(req.query).toEqual({ username: "anuraghazra", theme: "dark", @@ -72,6 +73,6 @@ describe("Test /api/wakatime backend routing", () => { ["Content-Type", "image/svg+xml"], ]); expect(res.end).toHaveBeenCalledExactlyOnceWith("mock-wakatime-svg"); - expect(mocks.storeRequest).toHaveBeenCalledExactlyOnceWith(req); + expect(storeRequestMock).toHaveBeenCalledExactlyOnceWith(req); }); }); diff --git a/packages/core/src/common/log.js b/packages/core/src/common/log.js deleted file mode 100644 index 88cf7dd9ae074..0000000000000 --- a/packages/core/src/common/log.js +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-check -/** - * Return console instance based on the environment. - * - * @type {Console | {log: () => void, error: () => void}} - */ -const logger = console; - -export { logger }; diff --git a/packages/core/src/common/log.ts b/packages/core/src/common/log.ts new file mode 100644 index 0000000000000..17c081e4b8960 --- /dev/null +++ b/packages/core/src/common/log.ts @@ -0,0 +1,12 @@ +/** + * Return console instance based on the environment. + */ +const logger: { + log: (...args: Array) => void; + error: (...args: Array) => void; +} = { + log: console.log, + error: console.error, +}; + +export { logger }; diff --git a/packages/core/tests/fetchStats.test.js b/packages/core/tests/fetchStats.test.js index 7d4531aaf124e..0593560c6323d 100644 --- a/packages/core/tests/fetchStats.test.js +++ b/packages/core/tests/fetchStats.test.js @@ -1,11 +1,16 @@ import axios from "axios"; import MockAdapter from "axios-mock-adapter"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { calculateRank } from "../src/calculateRank.js"; import { loadConfigFromEnv } from "../src/common/config.js"; import { fetchStats } from "../src/fetchers/stats.js"; +vi.mock(import("../src/common/log.js"), async () => { + const { createLoggerMock } = await import("./utils.js"); + return createLoggerMock(); +}); + // Test parameters. const data_stats = { data: { @@ -534,7 +539,6 @@ describe("Test fetchStats", () => { }); it("should return correct data when user don't have any pull requests", async () => { - mock.reset(); mock .onPost("https://api.github.com/graphql") .reply(200, data_without_pull_requests); diff --git a/packages/core/tests/fetchTopLanguages.test.js b/packages/core/tests/fetchTopLanguages.test.js index 798c5a38476f0..abf746b96f847 100644 --- a/packages/core/tests/fetchTopLanguages.test.js +++ b/packages/core/tests/fetchTopLanguages.test.js @@ -1,15 +1,25 @@ import axios from "axios"; import MockAdapter from "axios-mock-adapter"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchTopLanguages } from "../src/fetchers/top-languages.js"; import { approxNumber } from "./utils.js"; +vi.mock(import("../src/common/log.js"), async () => { + const { createLoggerMock } = await import("./utils.js"); + return createLoggerMock(); +}); + +const { logger } = await import("../src/index.js"); + +const loggerErrorSpy = vi.mocked(logger.error); + const mock = new MockAdapter(axios); afterEach(() => { mock.reset(); + loggerErrorSpy.mockClear(); }); const data_langs = { @@ -149,6 +159,8 @@ describe("FetchTopLanguages", () => { await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( "Could not resolve to a User with the login of 'noname'.", ); + + expect(loggerErrorSpy).toHaveBeenCalledOnce(); }); it("should throw other errors with their message", async () => { @@ -159,6 +171,8 @@ describe("FetchTopLanguages", () => { await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( "Some test GraphQL error", ); + + expect(loggerErrorSpy).toHaveBeenCalledOnce(); }); it("should throw error with specific message when error does not contain message property", async () => { @@ -169,5 +183,7 @@ describe("FetchTopLanguages", () => { await expect(fetchTopLanguages("anuraghazra")).rejects.toThrow( "Something went wrong while trying to retrieve the language data using the GraphQL API.", ); + + expect(loggerErrorSpy).toHaveBeenCalledOnce(); }); }); diff --git a/packages/core/tests/retryer.test.js b/packages/core/tests/retryer.test.js index 105720810cdf7..56f677f174ac6 100644 --- a/packages/core/tests/retryer.test.js +++ b/packages/core/tests/retryer.test.js @@ -1,59 +1,56 @@ // @ts-check -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { logger } from "../src/common/log.js"; import { retryer } from "../src/common/retryer.js"; -const fetcher = vi.fn((variables, token) => { - logger.log(variables, token); - return new Promise((res) => res({ data: "ok" })); +vi.mock(import("../src/common/log.js"), async () => { + const { createLoggerMock } = await import("./utils.js"); + return createLoggerMock(); }); -const fetcherFail = vi.fn(() => { - return new Promise((res) => - res({ data: { errors: [{ type: "RATE_LIMITED" }] } }), - ); +const logSpy = vi.mocked(logger.log); + +const fetcher = vi.fn().mockResolvedValue({ data: "ok" }); + +const fetcherFail = vi.fn().mockResolvedValue({ + data: { errors: [{ type: "RATE_LIMITED" }] }, }); const fetcherFailOnSecondTry = vi.fn((_vars, _token, retries) => { - return new Promise((res) => { - // faking rate limit - // @ts-ignore - if (retries < 1) { - return res({ data: { errors: [{ type: "RATE_LIMITED" }] } }); - } - return res({ data: "ok" }); - }); + if (retries < 1) { + return Promise.resolve({ data: { errors: [{ type: "RATE_LIMITED" }] } }); + } + return Promise.resolve({ data: "ok" }); }); const fetcherFailWithMessageBasedRateLimitErr = vi.fn( (_vars, _token, retries) => { - return new Promise((res) => { - // faking rate limit - // @ts-ignore - if (retries < 1) { - return res({ - data: { - errors: [ - { - type: "ASDF", - message: "API rate limit already exceeded for user ID 11111111", - }, - ], - }, - }); - } - return res({ data: "ok" }); - }); + if (retries < 1) { + return Promise.resolve({ + data: { + errors: [ + { + type: "ASDF", + message: "API rate limit already exceeded for user ID 11111111", + }, + ], + }, + }); + } + return Promise.resolve({ data: "ok" }); }, ); const customFetcher = vi.fn((variables, token) => { - logger.log(variables, token); return Promise.resolve({ data: { token } }); }); +afterEach(() => { + logSpy.mockClear(); +}); + describe("Test Retryer", () => { it("retryer should return value and have zero retries on first try", async () => { let res = await retryer(fetcher, {}); @@ -77,20 +74,21 @@ describe("Test Retryer", () => { }); it("retryer should throw specific error if maximum retries reached", async () => { - try { - await retryer(fetcherFail, {}); - } catch (err) { - expect(fetcherFail).toHaveBeenCalledTimes(2); - // @ts-ignore - expect(err.message).toBe("Downtime due to GitHub API rate limiting"); - } + await expect(retryer(fetcherFail, {})).rejects.toThrow( + "Downtime due to GitHub API rate limiting", + ); + + expect(fetcherFail).toHaveBeenCalledTimes(2); }); it("retryer should use injected PATs when provided", async () => { const res = await retryer(customFetcher, {}, "user-pat-token"); - expect(customFetcher).toHaveBeenCalledTimes(1); - expect(customFetcher).toHaveBeenCalledWith({}, "user-pat-token", 0); + expect(customFetcher).toHaveBeenCalledExactlyOnceWith( + {}, + "user-pat-token", + 0, + ); expect(res).toStrictEqual({ data: { token: "user-pat-token" } }); }); }); diff --git a/packages/core/tests/utils.js b/packages/core/tests/utils.js deleted file mode 100644 index 21b9f1f887391..0000000000000 --- a/packages/core/tests/utils.js +++ /dev/null @@ -1,41 +0,0 @@ -// @ts-check - -/** - * Creates an asymmetric matcher for approximate numeric equality. - * - * This helper is intended for use in test frameworks (e.g., Jest) where - * values need to be compared within a configurable decimal precision - * instead of strict equality. - * - * The comparison succeeds when: - * - * |actual - expected| < 10^(-precision) - * - * For example, with `precision = 3`, values must be within `0.001`. - * - * @param {number} expected The expected numeric value to compare against. - * - * @param {number} [precision=10] - * The number of decimal places of tolerance. Higher values mean stricter - * comparison. Internally converted to epsilon = 10^-precision. - * - * @returns {{ - * asymmetricMatch(actual: unknown): boolean, - * toAsymmetricMatcher(): string - * }} An object implementing Jest-style asymmetric matcher methods. - * - */ -export function approxNumber(expected, precision = 10) { - return { - asymmetricMatch(actual) { - if (typeof actual !== "number" || typeof expected !== "number") { - return false; - } - const epsilon = Math.pow(10, -precision); - return Math.abs(actual - expected) < epsilon; - }, - toAsymmetricMatcher() { - return `≈ ${expected} (precision ${precision})`; - }, - }; -} diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts new file mode 100644 index 0000000000000..1a4c5205ceb00 --- /dev/null +++ b/packages/core/tests/utils.ts @@ -0,0 +1,73 @@ +import { vi } from "vitest"; + +import type * as loggerModule from "../src/common/log.js"; + +/** + * Creates an asymmetric matcher for approximate numeric equality. + * + * This helper is intended for use in test frameworks (e.g., Jest) where + * values need to be compared within a configurable decimal precision + * instead of strict equality. + * + * The comparison succeeds when: + * + * |actual - expected| < 10^(-precision) + * + * For example, with `precision = 3`, values must be within `0.001`. + * + * @param expected The expected numeric value to compare against. + * + * @param + * The number of decimal places of tolerance. Higher values mean stricter + * comparison. Internally converted to epsilon = 10^-precision. + * + * @returns An object implementing Jest-style asymmetric matcher methods. + * + */ +export function approxNumber( + expected: number, + precision = 10, +): { + asymmetricMatch(actual: unknown): boolean; + toAsymmetricMatcher(): string; +} { + return { + asymmetricMatch(actual) { + if (typeof actual !== "number" || typeof expected !== "number") { + return false; + } + const epsilon = Math.pow(10, -precision); + return Math.abs(actual - expected) < epsilon; + }, + toAsymmetricMatcher() { + return `≈ ${expected} (precision ${precision})`; + }, + }; +} + +/** + * Helper to create logger module mocks to use in unit tests. + * If you need to perform assertions on logger use `vi.mocked`. + * + * @example + * ```ts + * import { logger } from "../src/common/log.js"; + * + * vi.mock(import("../src/common/log.js"), async () => { + * const { createLoggerMock } = await import("./utils.js"); + * return createLoggerMock(); + * }); + * + * const logSpy = vi.mocked(logger.log); + * ``` + * + * @returns mocked logger module + */ +export function createLoggerMock(): typeof loggerModule { + return { + logger: { + log: vi.fn(), + error: vi.fn(), + }, + }; +}