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
41 changes: 40 additions & 1 deletion .github/workflows/merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,45 @@ jobs:
- name: Build
run: ${{ steps.detect-package-manager.outputs.runner }} next build

test:
needs:
- build
- docker
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3

- name: Detect package manager
id: detect-package-manager
run: |
if [ -f "${{ github.workspace }}/yarn.lock" ]; then
echo "manager=yarn" >> $GITHUB_OUTPUT
echo "command=install" >> $GITHUB_OUTPUT
echo "runner=yarn" >> $GITHUB_OUTPUT
exit 0
elif [ -f "${{ github.workspace }}/package.json" ]; then
echo "manager=npm" >> $GITHUB_OUTPUT
echo "command=ci" >> $GITHUB_OUTPUT
echo "runner=npx --no-install" >> $GITHUB_OUTPUT
exit 0
else
echo "Unable to determine package manager"
exit 1
fi

- name: Setup Node (v22)
uses: actions/setup-node@v4
with:
node-version: "22"
cache: ${{ steps.detect-package-manager.outputs.manager }}

- name: Install dependencies
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}

- name: Run tests
run: ${{ steps.detect-package-manager.outputs.runner }} vitest run --coverage

docker:
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -113,4 +152,4 @@ jobs:
- name: Stop Docker Compose
run: |
cd docker
docker compose -f docker-compose.yml down
docker compose -f docker-compose.yml down
72 changes: 72 additions & 0 deletions __tests__/hooks/useIntersectionObserver.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/// <reference types="vitest" />

import { act, renderHook, waitFor } from "@testing-library/react";
import { vi } from "vitest";

import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";

let triggerIntersection: ((isIntersecting: boolean) => void) | undefined;
let lastObserver: IntersectionObserverMock | undefined;

class IntersectionObserverMock {
readonly observe = vi.fn();
readonly unobserve = vi.fn();
readonly disconnect = vi.fn();

constructor(callback: IntersectionObserverCallback) {
lastObserver = this;
triggerIntersection = (isIntersecting: boolean) =>
callback(
[{ isIntersecting } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
);
}
}

describe("useIntersectionObserver", () => {
beforeAll(() => {
(global as unknown as { IntersectionObserver: typeof IntersectionObserver }).IntersectionObserver =
IntersectionObserverMock as unknown as typeof IntersectionObserver;
});

beforeEach(() => {
triggerIntersection = undefined;
lastObserver = undefined;
});

it("observes the element and updates when it enters or leaves the viewport", async () => {
const target = document.createElement("div");
const { result, unmount } = renderHook(() => {
const hook = useIntersectionObserver();
hook.ref.current = target;
return hook;
});

await waitFor(() => {
expect(lastObserver?.observe).toHaveBeenCalledWith(target);
expect(triggerIntersection).toBeDefined();
});

act(() => {
triggerIntersection?.(true);
});
expect(result.current.isIntersecting).toBe(true);

act(() => {
triggerIntersection?.(false);
});
expect(result.current.isIntersecting).toBe(false);

act(() => {
unmount();
});
expect(lastObserver?.unobserve).toHaveBeenCalledWith(target);
});

it("does not register the observer when there is no target ref", () => {
renderHook(() => useIntersectionObserver());

expect(lastObserver?.observe).not.toHaveBeenCalled();
expect(lastObserver?.unobserve).not.toHaveBeenCalled();
});
});
157 changes: 157 additions & 0 deletions __tests__/hooks/useSelectFile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/// <reference types="vitest" />

import { act, renderHook, waitFor } from "@testing-library/react";
import type React from "react";
import { vi } from "vitest";

import useSelectFile from "@/hooks/useSelectFile";

const mockToast = vi.fn();

vi.mock("@/hooks/useCustomToast", () => ({
__esModule: true,
default: () => mockToast,
}));

const originalImage = global.Image;
const originalCreateObjectURL = global.URL.createObjectURL;
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
const originalGetContext = HTMLCanvasElement.prototype.getContext;

let mockWidth = 100;
let mockHeight = 100;

class MockImage {
onload: (() => void) | null = null;
width = mockWidth;
height = mockHeight;

constructor() {
this.width = mockWidth;
this.height = mockHeight;
}

set src(_value: string) {
setTimeout(() => {
this.onload?.();
}, 0);
}
}

const createFile = (sizeInMb: number, type: string) =>
new File([new Uint8Array(sizeInMb * 1024 * 1024)], "upload", { type });

const createChangeEvent = (
file: File
): React.ChangeEvent<HTMLInputElement> =>
({
target: { files: [file] },
} as unknown as React.ChangeEvent<HTMLInputElement>);

describe("useSelectFile", () => {
beforeAll(() => {
vi.spyOn(global.URL, "createObjectURL").mockReturnValue("blob:mock");
vi.stubGlobal("Image", MockImage as any);
HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
drawImage: vi.fn(),
})) as typeof HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.toDataURL = vi
.fn()
.mockReturnValue("data:image/jpeg;base64,mocked");
});

afterAll(() => {
vi.unstubAllGlobals();
(global as unknown as { Image: typeof Image }).Image = originalImage;
global.URL.createObjectURL = originalCreateObjectURL;
HTMLCanvasElement.prototype.getContext = originalGetContext;
HTMLCanvasElement.prototype.toDataURL = originalToDataURL;
});

beforeEach(() => {
mockToast.mockClear();
mockWidth = 100;
mockHeight = 100;
});

it("rejects files that exceed the size limit", async () => {
const { result } = renderHook(() => useSelectFile(300, 300));
const largeFile = createFile(11, "image/png");

await act(async () => {
result.current.onSelectFile(createChangeEvent(largeFile));
});

expect(mockToast).toHaveBeenCalledWith({
title: "File size is too large",
description: "Maximum file size is 10MB.",
status: "error",
});
expect(result.current.selectedFile).toBeUndefined();
});

it("rejects files with unsupported types", async () => {
const { result } = renderHook(() => useSelectFile(300, 300));
const badFile = createFile(1, "application/pdf");

await act(async () => {
result.current.onSelectFile(createChangeEvent(badFile));
});

expect(mockToast).toHaveBeenCalledWith({
title: "File type not allowed",
description: "Only image file types are allowed (.png / .jpeg / .gif).",
status: "error",
});
expect(result.current.selectedFile).toBeUndefined();
});

it("rejects images that exceed allowed dimensions", async () => {
mockWidth = 700;
mockHeight = 700;

const { result } = renderHook(() => useSelectFile(500, 500));
const tallImage = createFile(1, "image/png");

await act(async () => {
result.current.onSelectFile(createChangeEvent(tallImage));
});

await waitFor(() =>
expect(mockToast).toHaveBeenCalledWith({
title: "Image dimensions are too large",
description: "Maximum dimensions are 500x500.",
status: "error",
})
);
expect(result.current.selectedFile).toBeUndefined();
});

it("ignores events without a file", async () => {
const { result } = renderHook(() => useSelectFile(500, 500));

await act(async () => {
result.current.onSelectFile({ target: { files: [] } } as any);
});

expect(mockToast).not.toHaveBeenCalled();
expect(result.current.selectedFile).toBeUndefined();
});

it("sets the selected file for valid images", async () => {
mockWidth = 250;
mockHeight = 250;

const { result } = renderHook(() => useSelectFile(500, 500));
const validFile = createFile(1, "image/jpeg");

await act(async () => {
result.current.onSelectFile(createChangeEvent(validFile));
});

await waitFor(() =>
expect(result.current.selectedFile).toBe("data:image/jpeg;base64,mocked")
);
expect(mockToast).not.toHaveBeenCalled();
});
});
59 changes: 59 additions & 0 deletions __tests__/lib/communityPermissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/// <reference types="vitest" />

import {
checkCommunityPermission,
checkCommunityViewPermission,
} from "@/lib/community/communityPermissions";
import { Community, CommunitySnippet } from "@/types/community";

const baseCommunity: Community = {
id: "fitness",
creatorId: "owner",
numberOfMembers: 10,
privacyType: "public",
};

const memberSnippets: CommunitySnippet[] = [{ communityId: "fitness" }];
const noMembership: CommunitySnippet[] = [];

describe("checkCommunityPermission", () => {
it("allows actions in public communities", () => {
expect(
checkCommunityPermission(baseCommunity, noMembership)
).toBeTruthy();
});

it("allows members in restricted communities", () => {
const community = { ...baseCommunity, privacyType: "restricted" as const };

expect(checkCommunityPermission(community, memberSnippets)).toBe(true);
});

it("blocks non-members in restricted communities", () => {
const community = { ...baseCommunity, privacyType: "restricted" as const };

expect(checkCommunityPermission(community, noMembership)).toBe(false);
});

it("allows only members in private communities", () => {
const community = { ...baseCommunity, privacyType: "private" as const };

expect(checkCommunityPermission(community, memberSnippets)).toBe(true);
expect(checkCommunityPermission(community, noMembership)).toBe(false);
});
});

describe("checkCommunityViewPermission", () => {
it("allows viewing restricted communities without membership", () => {
const community = { ...baseCommunity, privacyType: "restricted" as const };

expect(checkCommunityViewPermission(community, noMembership)).toBe(true);
});

it("only allows private communities for members", () => {
const community = { ...baseCommunity, privacyType: "private" as const };

expect(checkCommunityViewPermission(community, memberSnippets)).toBe(true);
expect(checkCommunityViewPermission(community, noMembership)).toBe(false);
});
});
Loading
Loading