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
156 changes: 156 additions & 0 deletions src/__tests__/sites-crud.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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 { addSite, editSite, deleteSite } from "@/app/sites/actions"

describe("addSite action", () => {
beforeEach(() => {
vi.clearAllMocks()
})

test("redirects unauthenticated users to login", async () => {
mockGetUser.mockResolvedValue({ data: { user: null } })

const formData = new FormData()
formData.set("name", "Test Site")
formData.set("url", "https://example.com")

await expect(addSite(formData)).rejects.toThrow("NEXT_REDIRECT:/login")
})

test("inserts site 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("name", "Test Site")
formData.set("url", "https://example.com")

await addSite(formData)
expect(mockFrom).toHaveBeenCalledWith("sites")
expect(mockInsert).toHaveBeenCalledWith({
name: "Test Site",
url: "https://example.com",
})
})

test("does nothing if name or url is empty", async () => {
mockGetUser.mockResolvedValue({
data: { user: { id: "user-1" } },
})

const formData = new FormData()
formData.set("name", "Test Site")
// url is missing

await addSite(formData)
expect(mockFrom).not.toHaveBeenCalled()
})
})

describe("editSite action", () => {
beforeEach(() => {
vi.clearAllMocks()
})

test("redirects unauthenticated users to login", async () => {
mockGetUser.mockResolvedValue({ data: { user: null } })

const formData = new FormData()
formData.set("name", "Updated")
formData.set("url", "https://example.com")

await expect(editSite("site-1", formData)).rejects.toThrow(
"NEXT_REDIRECT:/login"
)
})

test("updates site for authenticated users", async () => {
mockGetUser.mockResolvedValue({
data: { user: { id: "user-1" } },
})

const mockEq = vi.fn().mockReturnValue({ error: null })
const mockUpdate = vi.fn().mockReturnValue({ eq: mockEq })
mockFrom.mockReturnValue({ update: mockUpdate })

const formData = new FormData()
formData.set("name", "Updated Site")
formData.set("url", "https://updated.com")

await editSite("site-1", formData)
expect(mockFrom).toHaveBeenCalledWith("sites")
expect(mockUpdate).toHaveBeenCalledWith({
name: "Updated Site",
url: "https://updated.com",
})
expect(mockEq).toHaveBeenCalledWith("id", "site-1")
})

test("does nothing if name or url is empty", async () => {
mockGetUser.mockResolvedValue({
data: { user: { id: "user-1" } },
})

const formData = new FormData()
// both fields empty

await editSite("site-1", formData)
expect(mockFrom).not.toHaveBeenCalled()
})
})

describe("deleteSite action", () => {
beforeEach(() => {
vi.clearAllMocks()
})

test("redirects unauthenticated users to login", async () => {
mockGetUser.mockResolvedValue({ data: { user: null } })

await expect(deleteSite("site-1")).rejects.toThrow("NEXT_REDIRECT:/login")
})

test("deletes site 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 deleteSite("site-1")
expect(mockFrom).toHaveBeenCalledWith("sites")
expect(mockEq).toHaveBeenCalledWith("id", "site-1")
})
})
97 changes: 60 additions & 37 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import Link from "next/link"
import { createClient } from "@/lib/supabase/server"
import type { Site, Check, Incident } from "@/lib/supabase/types"
import { formatTimeAgo, formatDuration } from "@/lib/format"
import AddSiteCard from "@/components/AddSiteCard"
import EditSiteButton from "@/components/EditSiteButton"

export const revalidate = 0

Expand Down Expand Up @@ -37,14 +39,19 @@ async function getStatusData() {
})
}

const {
data: { user },
} = await supabase.auth.getUser()

return {
sites: sitesWithChecks,
incidents: (openIncidents ?? []) as IncidentWithSite[],
isAdmin: !!user,
}
}

export default async function StatusPage() {
const { sites, incidents } = await getStatusData()
const { sites, incidents, isAdmin } = await getStatusData()

return (
<main
Expand Down Expand Up @@ -118,58 +125,74 @@ export default async function StatusPage() {
Sites
</h2>

{sites.length === 0 ? (
{sites.length === 0 && !isAdmin ? (
<p className="text-sm" style={{ color: "#5C5C5C" }}>
No sites are being monitored yet.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{sites.map((site) => (
<Link
<div
key={site.id}
href={`/sites/${site.id}`}
className="block no-underline"
className="group relative"
style={{
backgroundColor: "#FFFFFF",
border: "1px solid #E8E4DF",
borderRadius: "4px",
padding: "16px 18px",
color: "inherit",
}}
>
<div className="flex items-center gap-2.5">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{
backgroundColor:
site.lastCheck?.status === "failure"
? "#C4453C"
: "#5A8A5A",
}}
/>
<span
className="text-sm font-bold truncate"
style={{ color: "#1A1A1A" }}
>
{site.name}
</span>
</div>
<div
className="mt-1.5 text-xs truncate"
style={{ color: "#5C5C5C" }}
>
{site.url}
</div>
<div
className="mt-2 text-[11px]"
style={{ color: "#8A8A8A", letterSpacing: "0.01em" }}
<Link
href={`/sites/${site.id}`}
className="block no-underline"
style={{ color: "inherit" }}
>
{site.lastCheck
? `Checked ${formatTimeAgo(site.lastCheck.checked_at)}`
: "No checks yet"}
</div>
</Link>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{
backgroundColor:
site.lastCheck?.status === "failure"
? "#C4453C"
: "#5A8A5A",
}}
/>
<span
className="text-sm font-bold truncate"
style={{ color: "#1A1A1A" }}
>
{site.name}
</span>
</div>
</div>
<div
className="mt-1.5 text-xs truncate"
style={{ color: "#5C5C5C" }}
>
{site.url}
</div>
<div
className="mt-2 text-[11px]"
style={{ color: "#8A8A8A", letterSpacing: "0.01em" }}
>
{site.lastCheck
? `Checked ${formatTimeAgo(site.lastCheck.checked_at)}`
: "No checks yet"}
</div>
</Link>
{isAdmin && (
<div className="absolute top-3 right-3">
<EditSiteButton
siteId={site.id}
name={site.name}
url={site.url}
/>
</div>
)}
</div>
))}
{isAdmin && <AddSiteCard />}
</div>
)}
</section>
Expand Down
52 changes: 52 additions & 0 deletions src/app/sites/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use server"

import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { createClient } from "@/lib/supabase/server"

export async function addSite(formData: FormData) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()

if (!user) redirect("/login")

const name = (formData.get("name") as string)?.trim()
const url = (formData.get("url") as string)?.trim()

if (!name || !url) return

await supabase.from("sites").insert({ name, url })
revalidatePath("/")
}

export async function editSite(siteId: string, formData: FormData) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()

if (!user) redirect("/login")

const name = (formData.get("name") as string)?.trim()
const url = (formData.get("url") as string)?.trim()

if (!name || !url) return

await supabase.from("sites").update({ name, url }).eq("id", siteId)
revalidatePath("/")
revalidatePath(`/sites/${siteId}`)
}

export async function deleteSite(siteId: string) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()

if (!user) redirect("/login")

await supabase.from("sites").delete().eq("id", siteId)
revalidatePath("/")
}
Loading