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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
branches: ["**"]
pull_request:
branches: ["main"]

jobs:
build:
name: Build and Test
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [20.x]

steps:
- uses: actions/checkout@v4

- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Run Linting
run: npm run lint

- name: Run Tests
run: npm test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ yarn-error.log*
next-env.d.ts

/generated/prisma

# Sentry Config File
.env.sentry-build-plugin
88 changes: 88 additions & 0 deletions __tests__/actions/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { getUser, requireAuth, signOut } from "@/actions/auth";
import {
mockAuthenticatedUser,
mockUnauthenticatedUser,
setupSupabaseMock,
mockSupabase,
} from "@/__tests__/mocks/supabase";
import { redirect } from "next/navigation";

// Mock dependencies
jest.mock("@/utils/supabase/server");
jest.mock("next/navigation", () => ({
redirect: jest.fn(),
}));

describe("Auth Actions", () => {
beforeEach(() => {
setupSupabaseMock();
jest.clearAllMocks();
});

describe("getUser", () => {
it("should return the user when authenticated", async () => {
mockAuthenticatedUser("user-123");
const result = await getUser();
expect(result.data.user?.id).toBe("user-123");
});

it("should return null user when unauthenticated", async () => {
mockUnauthenticatedUser();
const result = await getUser();
expect(result.data.user).toBeNull();
});
});

describe("requireAuth", () => {
it("should return user if authenticated", async () => {
mockAuthenticatedUser("user-123");
const user = await requireAuth();
expect(user?.id).toBe("user-123");
expect(redirect).not.toHaveBeenCalled();
});

it("should redirect to /login if unauthenticated", async () => {
mockUnauthenticatedUser();

try {
await requireAuth();
} catch (e) {
// redirect throws an error in Next.js, catch it
}

expect(redirect).toHaveBeenCalledWith("/login");
});
});

describe("signOut", () => {
it("should sign out and redirect", async () => {
mockSupabase.auth.signOut.mockResolvedValue({ error: null });

try {
await signOut();
} catch (e) {
// redirect throws
}

expect(mockSupabase.auth.signOut).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/login");
});

it("should log error if sign out fails", async () => {
const consoleSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
mockSupabase.auth.signOut.mockResolvedValue({
error: { message: "Failed", name: "AuthError", status: 500 },
});

await signOut();

expect(mockSupabase.auth.signOut).toHaveBeenCalled();
expect(redirect).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalled();

consoleSpy.mockRestore();
});
});
});
162 changes: 162 additions & 0 deletions __tests__/actions/members.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import {
addMemberToTeam,
getMembersForTeam,
importMembersToTeam,
removeMemberFromTeam,
updateMember,
} from "@/actions/members";
import { prismaMock } from "@/__tests__/mocks/prisma";
import {
mockAuthenticatedUser,
mockSupabase,
setupSupabaseMock,
} from "@/__tests__/mocks/supabase";
import { createMockMember, createMockTeam } from "@/__tests__/helpers/fixtures";

// Mock dependencies
jest.mock("@/utils/supabase/server");

describe("Member Actions", () => {
const userId = "user-123";
const teamId = "team-123";

beforeEach(() => {
console.log("Prisma Mock:", prismaMock); // Debug log
setupSupabaseMock();
mockAuthenticatedUser(userId);
jest.clearAllMocks();
});

describe("addMemberToTeam", () => {
it("should successfully add a new member", async () => {
const email = "new@example.com";
const fullName = "New Member";
const mockTeam = createMockTeam({ id: teamId, leader_id: userId });

// Mock team ownership check
prismaMock.teams.findFirst.mockResolvedValue(mockTeam);

// Mock existing member check (not found)
prismaMock.members.findUnique.mockResolvedValue(null);
// Mock create member
const newMember = createMockMember({ email, full_name: fullName });
prismaMock.members.create.mockResolvedValue(newMember);

// Mock existing membership
prismaMock.team_members.findUnique.mockResolvedValue(null);

// Mock create membership
prismaMock.team_members.create.mockResolvedValue({} as any);

const result = await addMemberToTeam(teamId, email, fullName);

expect(result.success).toBe(true);
expect(prismaMock.teams.findFirst).toHaveBeenCalledWith({
where: { id: teamId, leader_id: userId },
});
expect(prismaMock.team_members.create).toHaveBeenCalled();
});

it("should fail if user is not authorized", async () => {
// Mock no user
mockSupabase.auth.getUser.mockResolvedValue({
data: { user: null },
error: null,
});

await expect(
addMemberToTeam(teamId, "test@test.com", "Test"),
).rejects.toThrow("Unauthorized Action");
});

it("should return error for invalid email", async () => {
const result = await addMemberToTeam(teamId, "invalid-email", "Name");
expect(result.success).toBe(false);
expect(result.error).toContain("Must be a valid email address");
});
});

describe("getMembersForTeam", () => {
it("should return members for a team", async () => {
const mockTeam = createMockTeam({ id: teamId, leader_id: userId });
prismaMock.teams.findFirst.mockResolvedValue(mockTeam);

const members = [
{
member: createMockMember({ email: "m1@test.com" }),
added_at: new Date(),
},
{
member: createMockMember({ email: "m2@test.com" }),
added_at: new Date(),
},
];

// Mock prisma response structure roughly matching the include
prismaMock.team_members.findMany.mockResolvedValue(members as any);

const result = await getMembersForTeam(teamId);

// Verify object return structure
if (result.success) {
expect(result.members).toHaveLength(2);
expect(result.members![0].email).toBe("m1@test.com");
} else {
fail("Expected success to be true");
}
});
});

describe("importMembersToTeam", () => {
it("should import valid members", async () => {
const membersToImport = [
{ email: "test1@example.com", full_name: "Test 1" },
{ email: "test2@example.com", full_name: "Test 2" },
];

const mockTeam = createMockTeam({ id: teamId, leader_id: userId });
prismaMock.teams.findFirst.mockResolvedValue(mockTeam);

// Mock member check/create
prismaMock.members.findUnique.mockResolvedValue(null);
prismaMock.members.create.mockImplementation(
(args) => createMockMember({ email: args.data.email }) as any,
);

// Mock team membership check/create
prismaMock.team_members.findUnique.mockResolvedValue(null);
prismaMock.team_members.create.mockResolvedValue({} as any);

const result = await importMembersToTeam(teamId, membersToImport);

expect(result.success).toBe(true);
// Verify added count
if (result.success) {
expect(result.added).toBe(2);
}
});

it("should handle partial failures or invalid data gracefully", async () => {
const invalidMembers = [{ email: "invalid", full_name: "Bad" }];
const result = await importMembersToTeam(teamId, invalidMembers);

// Validation failure returns early
expect(result.success).toBe(false);
});
});

describe("removeMemberFromTeam", () => {
it("should remove a member", async () => {
const memberId = "member-1";
const mockTeam = createMockTeam({ id: teamId, leader_id: userId });
prismaMock.teams.findFirst.mockResolvedValue(mockTeam);

prismaMock.team_members.delete.mockResolvedValue({} as any);

const result = await removeMemberFromTeam(teamId, memberId);

expect(result.success).toBe(true);
expect(prismaMock.team_members.delete).toHaveBeenCalled();
});
});
});
Loading
Loading