diff --git a/src/__tests__/contacts.test.ts b/src/__tests__/contacts.test.ts new file mode 100644 index 0000000..e746a66 --- /dev/null +++ b/src/__tests__/contacts.test.ts @@ -0,0 +1,96 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" + +// Mock next/cache +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})) + +// Mock next/navigation +const mockRedirect = vi.fn() +vi.mock("next/navigation", () => ({ + redirect: (url: string) => { + mockRedirect(url) + throw new Error(`NEXT_REDIRECT:${url}`) + }, +})) + +// Mock Supabase server client +const mockFrom = vi.fn() +const mockGetUser = vi.fn() + +vi.mock("@/lib/supabase/server", () => ({ + createClient: () => + Promise.resolve({ + from: mockFrom, + auth: { getUser: mockGetUser }, + }), +})) + +import { addContact, deleteContact } from "@/app/settings/actions" + +describe("addContact action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("redirects unauthenticated users to login", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + const formData = new FormData() + formData.set("email", "test@example.com") + + await expect(addContact(formData)).rejects.toThrow("NEXT_REDIRECT:/login") + }) + + test("inserts contact for authenticated users", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockInsert = vi.fn().mockReturnValue({ error: null }) + mockFrom.mockReturnValue({ insert: mockInsert }) + + const formData = new FormData() + formData.set("email", "test@example.com") + + await addContact(formData) + expect(mockFrom).toHaveBeenCalledWith("contacts") + expect(mockInsert).toHaveBeenCalledWith({ email: "test@example.com" }) + }) + + test("does nothing if email is empty", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const formData = new FormData() + await addContact(formData) + expect(mockFrom).not.toHaveBeenCalled() + }) +}) + +describe("deleteContact action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("redirects unauthenticated users to login", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + await expect(deleteContact("contact-1")).rejects.toThrow("NEXT_REDIRECT:/login") + }) + + test("deletes contact for authenticated users", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockEq = vi.fn().mockReturnValue({ error: null }) + const mockDelete = vi.fn().mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ delete: mockDelete }) + + await deleteContact("contact-1") + expect(mockFrom).toHaveBeenCalledWith("contacts") + expect(mockEq).toHaveBeenCalledWith("id", "contact-1") + }) +}) diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts new file mode 100644 index 0000000..1fcab26 --- /dev/null +++ b/src/app/settings/actions.ts @@ -0,0 +1,32 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" +import { createClient } from "@/lib/supabase/server" + +export async function addContact(formData: FormData) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect("/login") + + const email = formData.get("email") as string + if (!email) return + + await supabase.from("contacts").insert({ email }) + revalidatePath("/settings") +} + +export async function deleteContact(contactId: string) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect("/login") + + await supabase.from("contacts").delete().eq("id", contactId) + revalidatePath("/settings") +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..27abcdd --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,108 @@ +import { redirect } from "next/navigation" +import { createClient } from "@/lib/supabase/server" +import type { Contact } from "@/lib/supabase/types" +import { addContact, deleteContact } from "./actions" +import Link from "next/link" + +export const revalidate = 0 + +export default async function SettingsPage() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect("/login") + + const { data: contacts } = await supabase + .from("contacts") + .select("*") + .order("created_at") + + const typedContacts = (contacts ?? []) as Contact[] + + return ( +
+
+ + ← Back + +
+ +
+

+ Contacts +

+

+ All contacts are notified by email when an incident is opened. +

+ + + +
+ + +
+
+
+ ) +}