Skip to content

Commit

Permalink
Merge pull request #107 from arnaugomez/feat/utils-unit-tests
Browse files Browse the repository at this point in the history
Feat: unit tests of utils functions
  • Loading branch information
arnaugomez authored Jun 27, 2024
2 parents 55d5160 + 01e8b9b commit 6d162a8
Show file tree
Hide file tree
Showing 34 changed files with 558 additions and 65 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
"jsdom": "^24.1.0",
"lucia": "^3.2.0",
"lucide-react": "^0.396.0",
"memoize-one": "^6.0.0",
"mongodb": "^6.7.0",
"next": "^14.2.4",
"next-themes": "^0.3.0",
Expand All @@ -75,6 +74,8 @@
"devDependencies": {
"@next/env": "^14.2.4",
"@playwright/test": "^1.45.0",
"@testing-library/dom": "^10.2.0",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^15.0.7",
"@types/dompurify": "^3.0.5",
"@types/jsdom": "^21.1.7",
Expand Down
156 changes: 121 additions & 35 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { DatabaseService } from "@/src/common/domain/interfaces/database-service";
import { waitMilliseconds } from "@/src/common/domain/utils/promises";
import { waitMilliseconds } from "@/src/common/domain/utils/promise";
import { ObjectId } from "mongodb";
import { TimeSpan, createDate } from "oslo";
import { alphabet, generateRandomString } from "oslo/crypto";
Expand Down
44 changes: 44 additions & 0 deletions src/auth/domain/models/user-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { AuthTypeModel } from "./auth-type-model";
import type { UserModelData } from "./user-model";
import { UserModel } from "./user-model";

describe("UserModel", () => {
const mockData: UserModelData = {
id: "user-123",
email: "test@example.com",
authTypes: [AuthTypeModel.email],
isEmailVerified: true,
};

it("should instantiate correctly with provided data", () => {
const user = new UserModel(mockData);
expect(user).toBeInstanceOf(UserModel);
});

it("should have getters id, email and authTypes and isEmailVerified that match the values of the constructor argument", () => {
const user = new UserModel(mockData);
expect(user.id).toBe(mockData.id);
expect(user.email).toBe(mockData.email);
expect(user.authTypes).toEqual(mockData.authTypes);
expect(user.isEmailVerified).toBe(mockData.isEmailVerified);
});

it("isEmailVerified should be false if the data value is undefined", () => {
const userDataWithoutEmailVerification = {
...mockData,
isEmailVerified: undefined,
};
const user = new UserModel(userDataWithoutEmailVerification);
expect(user.isEmailVerified).toBe(false);
});

it("isEmailVerified should be false if the data value is false", () => {
const userDataWithEmailVerificationFalse = {
...mockData,
isEmailVerified: false,
};
const user = new UserModel(userDataWithEmailVerificationFalse);
expect(user.isEmailVerified).toBe(false);
});
});
4 changes: 2 additions & 2 deletions src/auth/domain/models/user-model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AuthTypeModel } from "./auth-type-model";

interface UserModelData {
export interface UserModelData {
id: string;
email: string;
authTypes: AuthTypeModel[];
Expand Down Expand Up @@ -36,6 +36,6 @@ export class UserModel {
* Whether the user has verified their email address
*/
get isEmailVerified() {
return this.data.isEmailVerified;
return this.data.isEmailVerified ?? false;
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client";

import { clientLocator } from "@/src/common/di/client-locator";
import { waitMilliseconds } from "@/src/common/domain/utils/promises";
import { waitMilliseconds } from "@/src/common/domain/utils/promise";
import { FormGlobalErrorMessage } from "@/src/common/ui/components/form/form-global-error-message";
import { FormSubmitButton } from "@/src/common/ui/components/form/form-submit-button";
import { InputFormField } from "@/src/common/ui/components/form/input-form-field";
import { Button } from "@/src/common/ui/components/shadcn/ui/button";
import { FormResponseHandler } from "@/src/common/ui/models/server-form-errors";
import { zodResolver } from "@hookform/resolvers/zod";
Expand All @@ -14,7 +15,6 @@ import { FormProvider, useForm } from "react-hook-form";
import { forgotPasswordAction } from "../actions/forgot-password-action";
import type { ForgotPasswordActionModel } from "../schemas/forgot-password-action-schema";
import { ForgotPasswordActionSchema } from "../schemas/forgot-password-action-schema";
import { InputFormField } from "@/src/common/ui/components/form/input-form-field";

const ForgotPasswordConfirmDialog = dynamic(() =>
import("./forgot-password-confirm-dialog").then(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ import { FormSubmitButton } from "@/src/common/ui/components/form/form-submit-bu
import { Button } from "@/src/common/ui/components/shadcn/ui/button";

import { clientLocator } from "@/src/common/di/client-locator";
import { waitMilliseconds } from "@/src/common/domain/utils/promises";
import { waitMilliseconds } from "@/src/common/domain/utils/promise";
import { PasswordSchema } from "@/src/common/schemas/password-schema";
import { PasswordInputFormField } from "@/src/common/ui/components/form/password-input-form-field";
import { FormResponseHandler } from "@/src/common/ui/models/server-form-errors";
import { zodResolver } from "@hookform/resolvers/zod";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { resetPasswordAction } from "../actions/reset-password-action";
import { PasswordInputFormField } from "@/src/common/ui/components/form/password-input-form-field";

const ResetPasswordConfirmDialog = dynamic(() =>
import("./reset-password-confirm-dialog").then(
Expand Down
2 changes: 1 addition & 1 deletion src/auth/ui/login/actions/login-with-password-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
IncorrectPasswordError,
UserDoesNotExistError,
} from "@/src/auth/domain/errors/auth-errors";
import { waitMilliseconds } from "@/src/common/domain/utils/promises";
import { waitMilliseconds } from "@/src/common/domain/utils/promise";
import { ActionErrorHandler } from "@/src/common/ui/actions/action-error-handler";
import { ActionResponse } from "@/src/common/ui/models/server-form-errors";
import { redirect } from "next/navigation";
Expand Down
8 changes: 4 additions & 4 deletions src/auth/ui/login/components/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { FormProvider, useForm } from "react-hook-form";

import { waitMilliseconds } from "@/src/common/domain/utils/promises";
import { clientLocator } from "@/src/common/di/client-locator";
import { waitMilliseconds } from "@/src/common/domain/utils/promise";
import { FormGlobalErrorMessage } from "@/src/common/ui/components/form/form-global-error-message";
import { FormSubmitButton } from "@/src/common/ui/components/form/form-submit-button";
import { InputFormField } from "@/src/common/ui/components/form/input-form-field";
import { PasswordInputFormField } from "@/src/common/ui/components/form/password-input-form-field";
import { Button } from "@/src/common/ui/components/shadcn/ui/button";
import { FormResponseHandler } from "@/src/common/ui/models/server-form-errors";
import { textStyles } from "@/src/common/ui/styles/text-styles";
Expand All @@ -14,9 +17,6 @@ import Link from "next/link";
import { loginWithPasswordAction } from "../actions/login-with-password-action";
import type { LoginWithPasswordActionModel } from "../schemas/login-with-password-action-schema";
import { LoginWithPasswordActionSchema } from "../schemas/login-with-password-action-schema";
import { clientLocator } from "@/src/common/di/client-locator";
import { InputFormField } from "@/src/common/ui/components/form/input-form-field";
import { PasswordInputFormField } from "@/src/common/ui/components/form/password-input-form-field";

export function LoginForm() {
const form = useForm({
Expand Down
2 changes: 1 addition & 1 deletion src/auth/ui/signup/components/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { FormProvider, useForm } from "react-hook-form";

import { clientLocator } from "@/src/common/di/client-locator";
import { waitMilliseconds } from "@/src/common/domain/utils/promises";
import { waitMilliseconds } from "@/src/common/domain/utils/promise";
import { EmailSchema } from "@/src/common/schemas/email-schema";
import { PasswordSchema } from "@/src/common/schemas/password-schema";
import { CheckboxFormField } from "@/src/common/ui/components/form/checkbox-form-field";
Expand Down
6 changes: 3 additions & 3 deletions src/auth/ui/verify-email/components/verify-email-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ import { z } from "@/i18n/zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { FormProvider, useForm } from "react-hook-form";

import { waitMilliseconds } from "@/src/common/domain/utils/promises";
import { clientLocator } from "@/src/common/di/client-locator";
import { waitMilliseconds } from "@/src/common/domain/utils/promise";
import { AsyncButton } from "@/src/common/ui/components/button/async-button";
import { FormGlobalErrorMessage } from "@/src/common/ui/components/form/form-global-error-message";
import { FormSubmitButton } from "@/src/common/ui/components/form/form-submit-button";
import { InputOtpFormField } from "@/src/common/ui/components/form/input-otp-form-field";
import { FormResponseHandler } from "@/src/common/ui/models/server-form-errors";
import { useEffect, useRef } from "react";
import { logoutAction } from "../../actions/logout-action";
import { verifyEmailAction } from "../actions/verify-email-action";
import { clientLocator } from "@/src/common/di/client-locator";
import { InputOtpFormField } from "@/src/common/ui/components/form/input-otp-form-field";

const FormSchema = z.object({
code: z.string().length(6),
Expand Down
15 changes: 15 additions & 0 deletions src/common/data/utils/mongo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { collection } from "./mongo";

interface MockDoc {
foo: string;
bar: number;
}

describe("collection", () => {
it("should return an object with the correct name property", () => {
const collectionName = "testCollection";
const result = collection<MockDoc>(collectionName);
expect(result.name).toBe(collectionName);
});
});
30 changes: 30 additions & 0 deletions src/common/di/locator-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { singleton } from "./locator-utils";

describe("singleton", () => {
it("should always return the same result", () => {
const memoizedFn = singleton(() => "test1");
const result1 = memoizedFn();
const result2 = memoizedFn();
const result3 = memoizedFn();

expect(result1).toBe("test1");
expect(result1).toBe(result2);
expect(result2).toBe(result3);

const memoizedFn2 = singleton(() => Symbol("test1"));
const result4 = memoizedFn2();
const result5 = memoizedFn2();
expect(result4).toBe(result5);
});

it("should always return the same result when the function is asynchronous", async () => {
const memoizedFn = singleton(async () => Symbol("test2"));
const result1 = await memoizedFn();
const result2 = await memoizedFn();
const result3 = await memoizedFn();

expect(result1).toBe(result2);
expect(result2).toBe(result3);
});
});
16 changes: 12 additions & 4 deletions src/common/di/locator-utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import memoizeOne from "memoize-one";

/**
* Memoizes the result of a function.
* @param resultFn The function to memoize.
* @param fn The function to memoize.
* @returns The memoized function.
*/
export const singleton = memoizeOne;
export function singleton<T>(fn: () => T) {
let isCached = false;
let cachedResult: T;
return () => {
if (!isCached) {
isCached = true;
cachedResult = fn();
}
return cachedResult;
};
}
18 changes: 18 additions & 0 deletions src/common/domain/utils/array.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { shuffle } from "./array";

const createArray = (length: number) => Array.from({ length }, (_, i) => i);

describe("shuffle", () => {
it("should return an array of the same length", () => {
const array = createArray(10);
const shuffledArray = shuffle(array);
expect(shuffledArray).toHaveLength(array.length);
});

it("should contain the same elements", () => {
const array = createArray(42);
const shuffledArray = shuffle(array);
expect(shuffledArray.sort()).toEqual(array.sort());
});
});
20 changes: 20 additions & 0 deletions src/common/domain/utils/promise.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import { waitMilliseconds } from "./promise";

describe("waitMilliseconds", () => {
it("should call setTimeout with the correct number of milliseconds", async () => {
const mock = vi.spyOn(global, "setTimeout");
const ms = 100;
await waitMilliseconds(ms);
expect(mock).toHaveBeenCalledWith(expect.anything(), ms);
mock.mockRestore();
});

it("should wait for at least the specified number of milliseconds", async () => {
const start = performance.now();
const ms = 80;
await waitMilliseconds(ms);
const end = performance.now();
expect(end - start).toBeGreaterThanOrEqual(ms);
});
});
File renamed without changes.
38 changes: 38 additions & 0 deletions src/common/ui/utils/context.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { mockConsoleError } from "@/test/utils/mock-console";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ReactContextNotFoundError } from "../models/context-errors";
import { createContextHook, createNullContext } from "./context"; // Adjust the import path as necessary

describe("createContextHook", () => {
it("throws CourseDoesNotExistError when context value is null", () => {
const TestContext = createNullContext<string>();
const useTestContext = createContextHook(TestContext);
const TestComponent = () => {
const value = useTestContext();
return <div>{value}</div>;
};

const mock = mockConsoleError();
expect(() => render(<TestComponent />)).toThrow(ReactContextNotFoundError);
mock.mockRestore();
});

it("returns the context value when it is not null", () => {
const TestContext = createNullContext<string>();
const useTestContext = createContextHook(TestContext);
const TestComponent = () => {
const value = useTestContext();
return <div>{value}</div>;
};

render(
<TestContext.Provider value="Test Value">
<TestComponent />
</TestContext.Provider>,
);

expect(screen.getByText("Test Value")).toBeInTheDocument();
});
});
4 changes: 2 additions & 2 deletions src/common/ui/utils/context.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { CourseDoesNotExistError } from "@/src/courses/domain/models/course-errors";
import type { Context } from "react";
import { createContext, useContext } from "react";
import { ReactContextNotFoundError } from "../models/context-errors";

export const createNullContext = <T>() => createContext<T | null>(null);

export function createContextHook<T>(context: Context<T | null>) {
return () => {
const value = useContext(context);
if (!value) throw new CourseDoesNotExistError();
if (!value) throw new ReactContextNotFoundError();
return value;
};
}
39 changes: 39 additions & 0 deletions src/common/ui/utils/shadcn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { cn } from "./shadcn";

describe("cn", () => {
test("does nothing when receives a single class", () => {
expect(cn("flex-col")).toBe("flex-col");
});

test("merges two classes and adds a space in between", () => {
expect(cn("btn", "btn-primary")).toBe("btn btn-primary");
});

test("handles conditional class inputs", () => {
let isActive = true;
expect(cn("btn", isActive && "active")).toBe("btn active");
isActive = false;
expect(cn("btn", isActive && "active")).toBe("btn");
});

test("removes duplicate classes", () => {
expect(cn("text-red", "text-red")).toBe("text-red");
});

test("merges TailwindCSS utility classes correctly", () => {
expect(cn("text-center", "text-center md:text-left")).toBe(
"text-center md:text-left",
);
});

test("handles array inputs", () => {
expect(cn(["btn", "btn-primary"])).toBe("btn btn-primary");
});

test("handles object inputs", () => {
expect(cn({ flex: true, "btn-primary": false, active: true })).toBe(
"flex active",
);
});
});
Loading

0 comments on commit 6d162a8

Please sign in to comment.