Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ next-env.d.ts
AGENTS.md

# ai
.github/agents/**
.github/copilot-instructions.md
.github/**
!.github/workflows/**
graphify-out
GEMINI.md
AGENTS.md
AGENTS.md
wiki/**
3 changes: 2 additions & 1 deletion __tests__/atoms/atoms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { defaultMenuItem, directoryMenuAtom } from "@/atoms/directoryMenuAtom";
import { postStateAtom } from "@/atoms/postsAtom";
import { savedPostStateAtom } from "@/atoms/savedPostsAtom";
import { ROUTES } from "@/constants/routes";

describe("communityStateAtom", () => {
it("defaults to empty snippets and snippetFetched false", () => {
Expand Down Expand Up @@ -86,7 +87,7 @@ describe("directoryMenuAtom", () => {
const state = store.get(directoryMenuAtom);
expect(state.isOpen).toBe(false);
expect(state.selectedMenuItem.displayText).toBe("Home");
expect(state.selectedMenuItem.link).toBe("/");
expect(state.selectedMenuItem.link).toBe(ROUTES.HOME.path);
});

it("defaultMenuItem has an icon and iconColor", () => {
Expand Down
19 changes: 19 additions & 0 deletions __tests__/constants/routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { ROUTES } from "@/constants/routes";

describe("ROUTES", () => {
it("returns the correct static routes", () => {
expect(ROUTES.HOME.path).toBe("/");
expect(ROUTES.COMMUNITIES.path).toBe("/communities");
expect(ROUTES.COMMUNITY.path).toBe("/community");
});

it("builds dynamic community routes", () => {
expect(ROUTES.COMMUNITY.detail("react")).toBe("/community/react");
expect(ROUTES.COMMUNITY.submit("react")).toBe("/community/react/submit");
expect(ROUTES.COMMUNITY.post("react", "post123")).toBe(
"/community/react/comments/post123",
);
});
});

5 changes: 4 additions & 1 deletion __tests__/hooks/community/useCreateCommunity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ vi.mock("@/hooks/useCustomToast", () => ({
default: () => mocks.toast,
}));

import { ROUTES } from "@/constants/routes";
import { useCreateCommunity } from "@/hooks/community/useCreateCommunity";

const wrapper = ({ children }: { children: ReactNode }) => (
Expand Down Expand Up @@ -87,7 +88,9 @@ describe("useCreateCommunity", () => {
await act(async () => {
await result.current.createCommunity("ValidName", "restricted");
});
expect(mocks.push).toHaveBeenCalledWith("/community/ValidName");
expect(mocks.push).toHaveBeenCalledWith(
ROUTES.COMMUNITY.detail("ValidName"),
);
});

it("clears a previous error on next attempt", async () => {
Expand Down
3 changes: 2 additions & 1 deletion __tests__/hooks/community/useDeleteCommunity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock("@/hooks/useCustomToast", () => ({
default: () => mocks.toast,
}));

import { ROUTES } from "@/constants/routes";
import useDeleteCommunity from "@/hooks/community/useDeleteCommunity";
import type { Community } from "@/types/community";

Expand Down Expand Up @@ -67,7 +68,7 @@ describe("useDeleteCommunity", () => {
title: "Community Deleted",
}),
);
expect(mocks.push).toHaveBeenCalledWith("/");
expect(mocks.push).toHaveBeenCalledWith(ROUTES.HOME.path);
});

it("shows an error toast and does not redirect on failure", async () => {
Expand Down
16 changes: 15 additions & 1 deletion __tests__/hooks/posts/useCallCreatePost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
useAuthState: vi.fn(),
push: vi.fn(),
toggleMenuOpen: vi.fn(),
params: vi.fn<() => Record<string, string | undefined>>(() => ({})),
}));

vi.mock("react-firebase-hooks/auth", () => ({
Expand All @@ -22,7 +23,7 @@ vi.mock("@/firebase/clientApp", () => ({

vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mocks.push }),
useParams: () => ({}),
useParams: () => mocks.params(),
usePathname: () => "/",
}));

Expand All @@ -31,6 +32,7 @@ vi.mock("@/hooks/useDirectory", () => ({
}));

import { authModalStateAtom } from "@/atoms/authModalAtom";
import { ROUTES } from "@/constants/routes";
import useCallCreatePost from "@/hooks/posts/useCallCreatePost";

let store: ReturnType<typeof createStore>;
Expand All @@ -41,6 +43,7 @@ const wrapper = ({ children }: { children: ReactNode }) => (
beforeEach(() => {
vi.clearAllMocks();
store = createStore();
mocks.params.mockReturnValue({});
});

describe("useCallCreatePost", () => {
Expand All @@ -67,4 +70,15 @@ describe("useCallCreatePost", () => {
expect(mocks.toggleMenuOpen).toHaveBeenCalled();
expect(store.get(authModalStateAtom).open).toBe(false);
});

it("navigates to community submit page when logged in with communityId", () => {
mocks.useAuthState.mockReturnValue([{ uid: "u1" }, false, undefined]);
mocks.params.mockReturnValue({ communityId: "c1" });
const { result } = renderHook(() => useCallCreatePost(), { wrapper });
act(() => {
result.current.onClick();
});
expect(mocks.push).toHaveBeenCalledWith(ROUTES.COMMUNITY.submit("c1"));
expect(mocks.toggleMenuOpen).not.toHaveBeenCalled();
});
});
3 changes: 2 additions & 1 deletion __tests__/hooks/posts/usePostSelection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ vi.mock("next/navigation", () => ({
}));

import { postStateAtom } from "@/atoms/postsAtom";
import { ROUTES } from "@/constants/routes";
import usePostSelection from "@/hooks/posts/usePostSelection";
import { Post, Timestamp } from "./helpers";

Expand All @@ -39,7 +40,7 @@ describe("usePostSelection", () => {
result.current.onSelectPost(post);
});
expect(store.get(postStateAtom).selectedPost?.id).toBe("p1");
expect(mocks.push).toHaveBeenCalledWith("/community/c1/comments/p1");
expect(mocks.push).toHaveBeenCalledWith(ROUTES.COMMUNITY.post("c1", "p1"));
});

it("preserves other state when selecting", () => {
Expand Down
23 changes: 12 additions & 11 deletions __tests__/hooks/useDirectory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ vi.mock("next/navigation", () => ({

import { communityStateAtom } from "@/atoms/communitiesAtom";
import { defaultMenuItem, directoryMenuAtom } from "@/atoms/directoryMenuAtom";
import { ROUTES } from "@/constants/routes";
import useDirectory from "@/hooks/useDirectory";
import type { Community } from "@/types/community";

Expand Down Expand Up @@ -49,7 +50,7 @@ function useSeeds() {

const menuItem = {
displayText: "Home",
link: "/",
link: ROUTES.HOME.path,
icon: expect.anything(),
iconColor: expect.anything(),
};
Expand All @@ -58,7 +59,7 @@ describe("useDirectory", () => {
beforeEach(() => {
vi.clearAllMocks();
store = createStore();
mocks.pathname.mockReturnValue("/");
mocks.pathname.mockReturnValue(ROUTES.HOME.path);
});

it("starts with the default menu state", () => {
Expand All @@ -85,10 +86,10 @@ describe("useDirectory", () => {

it("onSelectMenuItem sets the item and pushes its link", () => {
const { result } = renderHook(() => useDirectory(), { wrapper });
const item = { ...menuItem, link: "/communities" };
const item = { ...menuItem, link: ROUTES.COMMUNITIES.path };
act(() => result.current.onSelectMenuItem(item));
expect(result.current.directoryState.selectedMenuItem).toEqual(item);
expect(mocks.push).toHaveBeenCalledWith("/communities");
expect(mocks.push).toHaveBeenCalledWith(ROUTES.COMMUNITIES.path);
});

it("onSelectMenuItem closes the menu when it was open", () => {
Expand All @@ -106,19 +107,19 @@ describe("useDirectory", () => {
});

it("syncs selected item to the community when inside one on a community page", () => {
mocks.pathname.mockReturnValue("/community/c1");
mocks.pathname.mockReturnValue(ROUTES.COMMUNITY.detail("c1"));
const seeds = renderHook(() => useSeeds(), { wrapper }).result.current;
act(() => seeds.setCurrent(community({ imageURL: "img.png" })));
const { result } = renderHook(() => useDirectory(), { wrapper });
expect(result.current.directoryState.selectedMenuItem).toMatchObject({
displayText: "c1",
link: "community/c1",
link: ROUTES.COMMUNITY.detail("c1"),
imageURL: "img.png",
});
});

it("resets to the default item on the home page even with a current community", () => {
mocks.pathname.mockReturnValue("/");
mocks.pathname.mockReturnValue(ROUTES.HOME.path);
const seeds = renderHook(() => useSeeds(), { wrapper }).result.current;
act(() => seeds.selectItem("Other"));
act(() => seeds.setCurrent(community()));
Expand All @@ -129,25 +130,25 @@ describe("useDirectory", () => {
});

it("sets Communities item when on /communities even with a current community", () => {
mocks.pathname.mockReturnValue("/communities");
mocks.pathname.mockReturnValue(ROUTES.COMMUNITIES.path);
const seeds = renderHook(() => useSeeds(), { wrapper }).result.current;
act(() => seeds.setCurrent(community()));
const { result } = renderHook(() => useDirectory(), { wrapper });
expect(result.current.directoryState.selectedMenuItem).toMatchObject({
displayText: "Communities",
link: "/communities",
link: ROUTES.COMMUNITIES.path,
});
});

it("resets to defaultMenuItem when navigating back home", () => {
mocks.pathname.mockReturnValue("/communities");
mocks.pathname.mockReturnValue(ROUTES.COMMUNITIES.path);
const seeds = renderHook(() => useSeeds(), { wrapper }).result.current;
act(() => seeds.selectItem("Other"));
const { result, rerender } = renderHook(() => useDirectory(), { wrapper });
expect(result.current.directoryState.selectedMenuItem.displayText).toBe(
"Communities",
);
mocks.pathname.mockReturnValue("/");
mocks.pathname.mockReturnValue(ROUTES.HOME.path);
rerender();
expect(result.current.directoryState.selectedMenuItem).toEqual(
defaultMenuItem,
Expand Down
63 changes: 63 additions & 0 deletions __tests__/lib/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/// <reference types="vitest" />
import { env, validateEnv } from "@/lib/env";
import { describe, expect, it, vi } from "vitest";

const mockValidEnv = {
NEXT_PUBLIC_FIREBASE_API_KEY: "mock-key",
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: "mock-domain",
NEXT_PUBLIC_FIREBASE_PROJECT_ID: "mock-project",
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: "mock-bucket",
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: "mock-sender",
NEXT_PUBLIC_FIREBASE_APP_ID: "mock-app",
NODE_ENV: "test",
};

describe("lib/env", () => {
describe("validateEnv", () => {
it("validates client environment correctly when isServerEnv is false", () => {
const validated = validateEnv(mockValidEnv, false);
expect(validated.NEXT_PUBLIC_FIREBASE_API_KEY).toBe("mock-key");
expect(validated.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN).toBe("mock-domain");
});

it("validates server environment correctly when isServerEnv is true", () => {
const validated = validateEnv(mockValidEnv, true);
expect(validated.NEXT_PUBLIC_FIREBASE_PROJECT_ID).toBe("mock-project");
expect(validated.NODE_ENV).toBe("test");
});

it("throws error and logs to console when required variable is missing", () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});

const invalidEnv = { ...mockValidEnv, NEXT_PUBLIC_FIREBASE_API_KEY: "" };

expect(() => validateEnv(invalidEnv, false)).toThrow(
"Invalid environment variables",
);
expect(consoleSpy).toHaveBeenCalled();

consoleSpy.mockRestore();
});

it("validates with default arguments using process.env", () => {
const result = validateEnv();
expect(result).toBeDefined();
expect(result.NEXT_PUBLIC_FIREBASE_API_KEY).toBeDefined();
});
});

describe("env singleton", () => {
it("exports initialized and typed env singleton", () => {
expect(env).toBeDefined();
expect(typeof env.NEXT_PUBLIC_FIREBASE_API_KEY).toBe("string");
expect(typeof env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN).toBe("string");
expect(typeof env.NEXT_PUBLIC_FIREBASE_PROJECT_ID).toBe("string");
expect(typeof env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET).toBe("string");
expect(typeof env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID).toBe("string");
expect(typeof env.NEXT_PUBLIC_FIREBASE_APP_ID).toBe("string");
});
});
});

Loading
Loading