feat: ui tests

This commit is contained in:
Julian Sommer
2026-03-11 10:14:31 +10:00
parent 8f223d35d3
commit e26cd7fd82
16 changed files with 1391 additions and 4 deletions
+3
View File
@@ -42,5 +42,8 @@ jobs:
- name: Run Biome Check
run: pnpm check
- name: Run Tests
run: pnpm test
- name: Build Frontend
run: pnpm build
+1 -1
View File
@@ -38,7 +38,7 @@
"useFilenamingConvention": {
"level": "error",
"options": {
"filenameCases": ["camelCase"]
"filenameCases": ["camelCase", "kebab-case"]
}
}
}
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { ErrorBanner } from "./error-banner"
describe("ErrorBanner", () => {
it("renders the message", () => {
render(<ErrorBanner msg="Something went wrong" />)
expect(screen.getByText("Something went wrong")).toBeInTheDocument()
})
it("renders error.message when error.name is 'Error'", () => {
const error = new Error("connection refused")
render(<ErrorBanner error={error} />)
expect(screen.getByText("connection refused")).toBeInTheDocument()
})
it("renders error.name when it is a custom error type", () => {
const error = new TypeError("invalid type")
render(<ErrorBanner error={error} />)
expect(screen.getByText("TypeError")).toBeInTheDocument()
expect(screen.queryByText("invalid type")).not.toBeInTheDocument()
})
it("renders both msg and error together", () => {
const error = new Error("timeout")
render(<ErrorBanner error={error} msg="Request failed" />)
expect(screen.getByText("Request failed")).toBeInTheDocument()
expect(screen.getByText("timeout")).toBeInTheDocument()
})
it("renders no text content when no props are provided", () => {
const { container } = render(<ErrorBanner />)
expect(container.textContent).toBe("")
})
})
@@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { PageHeading } from "./page-heading"
describe("PageHeading", () => {
@@ -9,17 +10,26 @@ describe("PageHeading", () => {
})
it("renders the subtitle when provided", () => {
render(<PageHeading title="Instances" subtitle="Manage your EC2 instances" />)
render(
<PageHeading subtitle="Manage your EC2 instances" title="Instances" />,
)
expect(screen.getByText("Manage your EC2 instances")).toBeInTheDocument()
})
it("does not render a subtitle when not provided", () => {
render(<PageHeading title="Instances" />)
expect(screen.queryByText("Manage your EC2 instances")).not.toBeInTheDocument()
expect(
screen.queryByText("Manage your EC2 instances"),
).not.toBeInTheDocument()
})
it("renders actions when provided", () => {
render(<PageHeading title="Instances" actions={<button type="button">Launch</button>} />)
render(
<PageHeading
actions={<button type="button">Launch</button>}
title="Instances"
/>,
)
expect(screen.getByText("Launch")).toBeInTheDocument()
})
})
@@ -0,0 +1,51 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { StateBadge } from "./state-badge"
describe("StateBadge", () => {
it.each([
"running",
"available",
"completed",
"Active",
])("renders green for success state '%s'", (state) => {
render(<StateBadge state={state} />)
const badge = screen.getByText(state)
expect(badge.className).toContain("bg-green-100")
})
it.each(["stopped", "error"])("renders red for error state '%s'", (state) => {
render(<StateBadge state={state} />)
const badge = screen.getByText(state)
expect(badge.className).toContain("bg-red-100")
})
it.each([
"pending",
"shutting-down",
"stopping",
])("renders yellow for warning state '%s'", (state) => {
render(<StateBadge state={state} />)
const badge = screen.getByText(state)
expect(badge.className).toContain("bg-yellow-100")
})
it("renders gray for terminated state", () => {
render(<StateBadge state="terminated" />)
const badge = screen.getByText("terminated")
expect(badge.className).toContain("bg-zinc-100")
})
it("renders default gray for unknown state", () => {
render(<StateBadge state="something-else" />)
const badge = screen.getByText("something-else")
expect(badge.className).toContain("bg-gray-100")
})
it("renders default gray for undefined state", () => {
const { container } = render(<StateBadge state={undefined} />)
const badge = container.firstElementChild
expect(badge?.className).toContain("bg-gray-100")
})
})
@@ -0,0 +1,92 @@
import { act, renderHook } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { useCopyToClipboard } from "./use-copy-to-clipboard"
beforeEach(() => {
vi.useFakeTimers()
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
})
})
afterEach(() => {
vi.useRealTimers()
})
describe("useCopyToClipboard", () => {
it("starts with copied as false", () => {
const { result } = renderHook(() => useCopyToClipboard())
expect(result.current.copied).toBe(false)
})
it("sets copied to true after calling copy", async () => {
const { result } = renderHook(() => useCopyToClipboard())
await act(async () => {
await result.current.copy("hello")
})
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello")
expect(result.current.copied).toBe(true)
})
it("resets copied to false after 2 seconds", async () => {
const { result } = renderHook(() => useCopyToClipboard())
await act(async () => {
await result.current.copy("hello")
})
expect(result.current.copied).toBe(true)
act(() => {
vi.advanceTimersByTime(2000)
})
expect(result.current.copied).toBe(false)
})
it("resets the timer on rapid successive copies", async () => {
const { result } = renderHook(() => useCopyToClipboard())
await act(async () => {
await result.current.copy("first")
})
// Advance 1.5s, then copy again — should restart the 2s timer
act(() => {
vi.advanceTimersByTime(1500)
})
expect(result.current.copied).toBe(true)
await act(async () => {
await result.current.copy("second")
})
// 1.5s after second copy — still within the new 2s window
act(() => {
vi.advanceTimersByTime(1500)
})
expect(result.current.copied).toBe(true)
// 2s after second copy — should reset
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.copied).toBe(false)
})
it("clears the timer on unmount", async () => {
const { result, unmount } = renderHook(() => useCopyToClipboard())
await act(async () => {
await result.current.copy("hello")
})
unmount()
// Advancing time after unmount should not throw
act(() => {
vi.advanceTimersByTime(2000)
})
})
})
@@ -0,0 +1,82 @@
import { act, renderHook } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
let changeListener: (() => void) | null = null
function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockReturnValue({
matches,
addEventListener: (_event: string, cb: () => void) => {
changeListener = cb
},
removeEventListener: () => {
changeListener = null
},
}),
})
}
beforeEach(() => {
vi.resetModules()
changeListener = null
})
afterEach(() => {
changeListener = null
})
describe("useIsMobile", () => {
it("returns false on desktop viewport", async () => {
mockMatchMedia(false)
const { useIsMobile } = await import("./use-mobile")
const { result } = renderHook(() => useIsMobile())
expect(result.current).toBe(false)
})
it("returns true on mobile viewport", async () => {
mockMatchMedia(true)
const { useIsMobile } = await import("./use-mobile")
const { result } = renderHook(() => useIsMobile())
expect(result.current).toBe(true)
})
it("updates when viewport changes", async () => {
const mqlMock = {
matches: false,
addEventListener: (_event: string, cb: () => void) => {
changeListener = cb
},
removeEventListener: () => {
changeListener = null
},
}
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockReturnValue(mqlMock),
})
const { useIsMobile } = await import("./use-mobile")
const { result } = renderHook(() => useIsMobile())
expect(result.current).toBe(false)
// Simulate viewport shrink
mqlMock.matches = true
act(() => {
changeListener?.()
})
expect(result.current).toBe(true)
})
it("cleans up listener on unmount", async () => {
mockMatchMedia(false)
const { useIsMobile } = await import("./use-mobile")
const { unmount } = renderHook(() => useIsMobile())
expect(changeListener).not.toBeNull()
unmount()
expect(changeListener).toBeNull()
})
})
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { AwsCredentials } from "./auth"
const validCreds: AwsCredentials = {
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
let getCredentials: typeof import("./auth").getCredentials
let setCredentials: typeof import("./auth").setCredentials
let clearCredentials: typeof import("./auth").clearCredentials
beforeEach(async () => {
// Reset module to get a fresh in-memory cache for each test
vi.resetModules()
const auth = await import("./auth")
getCredentials = auth.getCredentials
setCredentials = auth.setCredentials
clearCredentials = auth.clearCredentials
})
afterEach(() => {
localStorage.clear()
})
describe("setCredentials", () => {
it("stores credentials in localStorage", () => {
setCredentials(validCreds)
const stored = localStorage.getItem("hive:v1:aws-credentials")
expect(JSON.parse(stored ?? "")).toEqual(validCreds)
})
it("caches credentials in memory", () => {
setCredentials(validCreds)
// Clear localStorage to prove it reads from cache
localStorage.clear()
expect(getCredentials()).toEqual(validCreds)
})
})
describe("getCredentials", () => {
it("returns null when nothing is stored", () => {
expect(getCredentials()).toBeNull()
})
it("reads from localStorage on first call", () => {
localStorage.setItem("hive:v1:aws-credentials", JSON.stringify(validCreds))
expect(getCredentials()).toEqual(validCreds)
})
it("returns cached value on subsequent calls", () => {
setCredentials(validCreds)
localStorage.clear()
// Should still return from cache
expect(getCredentials()).toEqual(validCreds)
expect(getCredentials()).toEqual(validCreds)
})
it("returns null for invalid JSON in localStorage", () => {
localStorage.setItem("hive:v1:aws-credentials", "not-json")
expect(getCredentials()).toBeNull()
})
it("returns null when stored data fails schema validation", () => {
localStorage.setItem(
"hive:v1:aws-credentials",
JSON.stringify({ accessKeyId: "short", secretAccessKey: "" }),
)
expect(getCredentials()).toBeNull()
})
it("returns null when stored object is missing fields", () => {
localStorage.setItem(
"hive:v1:aws-credentials",
JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE" }),
)
expect(getCredentials()).toBeNull()
})
})
describe("clearCredentials", () => {
it("removes credentials from localStorage", () => {
setCredentials(validCreds)
clearCredentials()
expect(localStorage.getItem("hive:v1:aws-credentials")).toBeNull()
})
it("clears the in-memory cache", () => {
setCredentials(validCreds)
clearCredentials()
expect(getCredentials()).toBeNull()
})
})
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest"
import {
buildFullS3Key,
cn,
ensureTrailingSlash,
extractDisplayName,
formatDateTime,
formatSize,
getNameTag,
removeTrailingSlash,
} from "./utils"
describe("cn", () => {
it("merges class names", () => {
expect(cn("foo", "bar")).toBe("foo bar")
})
it("handles conditional classes", () => {
expect(cn("foo", false, "baz")).toBe("foo baz")
})
it("deduplicates conflicting tailwind classes", () => {
expect(cn("p-4", "p-2")).toBe("p-2")
})
it("returns empty string with no inputs", () => {
expect(cn()).toBe("")
})
})
describe("formatDateTime", () => {
it("formats a Date object", () => {
const date = new Date("2025-06-15T10:30:00Z")
const result = formatDateTime(date)
expect(result).toContain("2025")
expect(result).toContain("15")
})
it("formats an ISO string", () => {
const result = formatDateTime("2025-01-01T00:00:00Z")
expect(result).toContain("2025")
})
it("returns 'Unknown' for undefined", () => {
expect(formatDateTime(undefined)).toBe("Unknown")
})
it("returns 'Unknown' for empty string", () => {
expect(formatDateTime("")).toBe("Unknown")
})
})
describe("formatSize", () => {
it("returns '0 B' for zero bytes", () => {
expect(formatSize(0)).toBe("0 B")
})
it("formats bytes", () => {
expect(formatSize(500)).toBe("500.00 B")
})
it("formats kilobytes", () => {
expect(formatSize(1024)).toBe("1.00 KB")
expect(formatSize(1536)).toBe("1.50 KB")
})
it("formats megabytes", () => {
expect(formatSize(1024 * 1024)).toBe("1.00 MB")
})
it("formats gigabytes", () => {
expect(formatSize(1024 ** 3)).toBe("1.00 GB")
})
it("formats terabytes", () => {
expect(formatSize(1024 ** 4)).toBe("1.00 TB")
})
})
describe("getNameTag", () => {
it("returns the Name tag value", () => {
const tags = [
{ Key: "Env", Value: "prod" },
{ Key: "Name", Value: "my-instance" },
]
expect(getNameTag(tags)).toBe("my-instance")
})
it("returns undefined when Name tag is missing", () => {
const tags = [{ Key: "Env", Value: "prod" }]
expect(getNameTag(tags)).toBeUndefined()
})
it("returns undefined for empty array", () => {
expect(getNameTag([])).toBeUndefined()
})
it("returns undefined for undefined input", () => {
expect(getNameTag(undefined)).toBeUndefined()
})
})
describe("removeTrailingSlash", () => {
it("removes trailing slash", () => {
expect(removeTrailingSlash("foo/")).toBe("foo")
})
it("leaves paths without trailing slash unchanged", () => {
expect(removeTrailingSlash("foo")).toBe("foo")
})
it("handles root slash", () => {
expect(removeTrailingSlash("/")).toBe("")
})
it("handles empty string", () => {
expect(removeTrailingSlash("")).toBe("")
})
it("only removes the last slash", () => {
expect(removeTrailingSlash("foo/bar/")).toBe("foo/bar")
})
})
describe("ensureTrailingSlash", () => {
it("adds trailing slash when missing", () => {
expect(ensureTrailingSlash("foo")).toBe("foo/")
})
it("does not double slash", () => {
expect(ensureTrailingSlash("foo/")).toBe("foo/")
})
it("returns empty string as-is", () => {
expect(ensureTrailingSlash("")).toBe("")
})
})
describe("buildFullS3Key", () => {
it("returns key as-is when it already starts with prefix", () => {
expect(buildFullS3Key("photos/cat.jpg", "photos/")).toBe("photos/cat.jpg")
})
it("prepends prefix to relative key", () => {
expect(buildFullS3Key("cat.jpg", "photos/")).toBe("photos/cat.jpg")
})
it("handles empty prefix", () => {
expect(buildFullS3Key("cat.jpg", "")).toBe("cat.jpg")
})
})
describe("extractDisplayName", () => {
it("strips prefix from absolute key", () => {
expect(extractDisplayName("photos/cat.jpg", "photos/")).toBe("cat.jpg")
})
it("returns relative key unchanged", () => {
expect(extractDisplayName("cat.jpg", "photos/")).toBe("cat.jpg")
})
it("handles empty prefix", () => {
expect(extractDisplayName("cat.jpg", "")).toBe("cat.jpg")
})
it("handles nested prefixes", () => {
expect(extractDisplayName("a/b/c/file.txt", "a/b/")).toBe("c/file.txt")
})
})
@@ -0,0 +1,218 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { renderHook, waitFor } from "@testing-library/react"
import type { ReactNode } from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
const mockSend = vi.fn().mockResolvedValue({})
vi.mock("@/lib/awsClient", () => ({
getEc2Client: () => ({ send: mockSend }),
}))
import {
useCreateInstance,
useCreateVpc,
useImportKeyPair,
useStartInstance,
useStopInstance,
useTerminateInstance,
} from "./ec2"
let queryClient: QueryClient
function wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
afterEach(() => {
mockSend.mockClear()
})
function createQueryClient() {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return queryClient
}
describe("useStartInstance", () => {
it("sends StartInstancesCommand with the instance ID", async () => {
createQueryClient()
const { result } = renderHook(() => useStartInstance(), { wrapper })
result.current.mutate("i-abc123")
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockSend).toHaveBeenCalledOnce()
const command = mockSend.mock.calls[0]?.[0]
expect(command.input).toEqual({ InstanceIds: ["i-abc123"] })
})
it("invalidates instances query on success", async () => {
createQueryClient()
const spy = vi.spyOn(queryClient, "invalidateQueries")
const { result } = renderHook(() => useStartInstance(), { wrapper })
result.current.mutate("i-abc123")
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(spy).toHaveBeenCalledWith({ queryKey: ["ec2", "instances"] })
})
})
describe("useStopInstance", () => {
it("sends StopInstancesCommand with the instance ID", async () => {
createQueryClient()
const { result } = renderHook(() => useStopInstance(), { wrapper })
result.current.mutate("i-abc123")
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
InstanceIds: ["i-abc123"],
})
})
})
describe("useTerminateInstance", () => {
it("sends TerminateInstancesCommand with the instance ID", async () => {
createQueryClient()
const { result } = renderHook(() => useTerminateInstance(), { wrapper })
result.current.mutate("i-abc123")
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
InstanceIds: ["i-abc123"],
})
})
})
describe("useCreateInstance", () => {
it("sends RunInstancesCommand with form data", async () => {
createQueryClient()
const { result } = renderHook(() => useCreateInstance(), { wrapper })
result.current.mutate({
imageId: "ami-123",
instanceType: "t2.micro",
keyName: "my-key",
count: 2,
subnetId: "subnet-1",
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
ImageId: "ami-123",
InstanceType: "t2.micro",
KeyName: "my-key",
MinCount: 2,
MaxCount: 2,
SubnetId: "subnet-1",
})
})
it("omits SubnetId when empty string", async () => {
createQueryClient()
const { result } = renderHook(() => useCreateInstance(), { wrapper })
result.current.mutate({
imageId: "ami-123",
instanceType: "t2.micro",
keyName: "my-key",
count: 1,
subnetId: "",
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockSend.mock.calls[0]?.[0].input.SubnetId).toBeUndefined()
})
})
describe("useImportKeyPair", () => {
it("strips the comment from an SSH public key", async () => {
createQueryClient()
const { result } = renderHook(() => useImportKeyPair(), { wrapper })
result.current.mutate({
keyName: "my-key",
publicKeyMaterial: "ssh-rsa AAAAB3Nza... user@host",
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
const input = mockSend.mock.calls[0]?.[0].input
expect(input.KeyName).toBe("my-key")
const decoded = new TextDecoder().decode(input.PublicKeyMaterial)
expect(decoded).toBe("ssh-rsa AAAAB3Nza...")
expect(decoded).not.toContain("user@host")
})
it("handles keys without comments", async () => {
createQueryClient()
const { result } = renderHook(() => useImportKeyPair(), { wrapper })
result.current.mutate({
keyName: "my-key",
publicKeyMaterial: "ssh-rsa AAAAB3Nza...",
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
const decoded = new TextDecoder().decode(
mockSend.mock.calls[0]?.[0].input.PublicKeyMaterial,
)
expect(decoded).toBe("ssh-rsa AAAAB3Nza...")
})
it("handles extra whitespace in key", async () => {
createQueryClient()
const { result } = renderHook(() => useImportKeyPair(), { wrapper })
result.current.mutate({
keyName: "my-key",
publicKeyMaterial: " ssh-rsa AAAAB3Nza... user@host ",
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
const decoded = new TextDecoder().decode(
mockSend.mock.calls[0]?.[0].input.PublicKeyMaterial,
)
expect(decoded).toBe("ssh-rsa AAAAB3Nza...")
})
})
describe("useCreateVpc", () => {
it("includes TagSpecifications when name is provided", async () => {
createQueryClient()
const { result } = renderHook(() => useCreateVpc(), { wrapper })
result.current.mutate({ cidrBlock: "10.0.0.0/16", name: "my-vpc" })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
CidrBlock: "10.0.0.0/16",
TagSpecifications: [
{
ResourceType: "vpc",
Tags: [{ Key: "Name", Value: "my-vpc" }],
},
],
})
})
it("omits TagSpecifications when name is empty", async () => {
createQueryClient()
const { result } = renderHook(() => useCreateVpc(), { wrapper })
result.current.mutate({ cidrBlock: "10.0.0.0/16", name: "" })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockSend.mock.calls[0]?.[0].input.TagSpecifications).toBeUndefined()
})
})
@@ -0,0 +1,113 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { renderHook, waitFor } from "@testing-library/react"
import type { ReactNode } from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
const mockSend = vi.fn().mockResolvedValue({})
vi.mock("@/lib/awsClient", () => ({
getS3Client: () => ({ send: mockSend }),
}))
vi.mock("@aws-sdk/lib-storage", () => ({
Upload: class MockUpload {
private key: string
constructor({ params }: { params: { Key: string } }) {
this.key = params.Key
}
done() {
return Promise.resolve({ Key: this.key })
}
},
}))
import { useCreateBucket, useDeleteObject, useUploadObject } from "./s3"
let queryClient: QueryClient
function wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
function createQueryClient() {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return queryClient
}
afterEach(() => {
mockSend.mockClear()
})
describe("useCreateBucket", () => {
it("sends CreateBucketCommand with bucket name", async () => {
createQueryClient()
const { result } = renderHook(() => useCreateBucket(), { wrapper })
result.current.mutate({ bucketName: "my-bucket" })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockSend.mock.calls[0]?.[0].input).toEqual({ Bucket: "my-bucket" })
})
it("invalidates buckets query on success", async () => {
createQueryClient()
const spy = vi.spyOn(queryClient, "invalidateQueries")
const { result } = renderHook(() => useCreateBucket(), { wrapper })
result.current.mutate({ bucketName: "my-bucket" })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(spy).toHaveBeenCalledWith({ queryKey: ["s3", "buckets"] })
})
})
describe("useDeleteObject", () => {
it("sends DeleteObjectCommand with bucket and key", async () => {
createQueryClient()
const { result } = renderHook(() => useDeleteObject(), { wrapper })
result.current.mutate({ bucket: "my-bucket", key: "photos/cat.jpg" })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
Bucket: "my-bucket",
Key: "photos/cat.jpg",
})
})
it("invalidates bucket objects query on success", async () => {
createQueryClient()
const spy = vi.spyOn(queryClient, "invalidateQueries")
const { result } = renderHook(() => useDeleteObject(), { wrapper })
result.current.mutate({ bucket: "my-bucket", key: "file.txt" })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(spy).toHaveBeenCalledWith({
queryKey: ["s3", "buckets", "my-bucket", "objects"],
})
})
})
describe("useUploadObject", () => {
it("uploads a file and invalidates bucket objects query", async () => {
createQueryClient()
const spy = vi.spyOn(queryClient, "invalidateQueries")
const { result } = renderHook(() => useUploadObject(), { wrapper })
const file = new File(["hello"], "test.txt", { type: "text/plain" })
result.current.mutate({ bucket: "my-bucket", key: "test.txt", file })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(spy).toHaveBeenCalledWith({
queryKey: ["s3", "buckets", "my-bucket", "objects"],
})
})
})
@@ -0,0 +1,157 @@
import { describe, expect, it, vi } from "vitest"
const mockSend = vi.fn().mockResolvedValue({})
vi.mock("@/lib/awsClient", () => ({
getEc2Client: () => ({ send: mockSend }),
}))
import {
ec2AvailabilityZonesQueryOptions,
ec2ImageQueryOptions,
ec2ImagesQueryOptions,
ec2InstanceQueryOptions,
ec2InstancesQueryOptions,
ec2InstanceTypesQueryOptions,
ec2KeyPairQueryOptions,
ec2KeyPairsQueryOptions,
ec2RegionsQueryOptions,
ec2SnapshotQueryOptions,
ec2SnapshotsQueryOptions,
ec2SubnetQueryOptions,
ec2SubnetsQueryOptions,
ec2VolumeQueryOptions,
ec2VolumesQueryOptions,
ec2VpcQueryOptions,
ec2VpcsQueryOptions,
} from "./ec2"
describe("query keys", () => {
it("ec2InstancesQueryOptions has correct key", () => {
expect(ec2InstancesQueryOptions.queryKey).toEqual(["ec2", "instances"])
})
it("ec2InstanceQueryOptions includes instanceId in key", () => {
expect(ec2InstanceQueryOptions("i-123").queryKey).toEqual([
"ec2",
"instances",
"i-123",
])
})
it("ec2ImagesQueryOptions has correct key", () => {
expect(ec2ImagesQueryOptions.queryKey).toEqual(["ec2", "images"])
})
it("ec2ImageQueryOptions uses 'none' for undefined imageId", () => {
expect(ec2ImageQueryOptions(undefined).queryKey).toEqual([
"ec2",
"images",
"none",
])
})
it("ec2KeyPairsQueryOptions has correct key", () => {
expect(ec2KeyPairsQueryOptions.queryKey).toEqual(["ec2", "keypairs"])
})
it("ec2KeyPairQueryOptions includes keyPairId", () => {
expect(ec2KeyPairQueryOptions("kp-abc").queryKey).toEqual([
"ec2",
"keypairs",
"kp-abc",
])
})
it("ec2VolumesQueryOptions has correct key", () => {
expect(ec2VolumesQueryOptions.queryKey).toEqual(["ec2", "volumes"])
})
it("ec2VolumeQueryOptions includes volumeId", () => {
expect(ec2VolumeQueryOptions("vol-1").queryKey).toEqual([
"ec2",
"volumes",
"vol-1",
])
})
it("ec2SnapshotsQueryOptions has correct key", () => {
expect(ec2SnapshotsQueryOptions.queryKey).toEqual(["ec2", "snapshots"])
})
it("ec2SnapshotQueryOptions includes snapshotId", () => {
expect(ec2SnapshotQueryOptions("snap-1").queryKey).toEqual([
"ec2",
"snapshots",
"snap-1",
])
})
it("ec2VpcsQueryOptions has correct key", () => {
expect(ec2VpcsQueryOptions.queryKey).toEqual(["ec2", "vpcs"])
})
it("ec2VpcQueryOptions includes vpcId", () => {
expect(ec2VpcQueryOptions("vpc-1").queryKey).toEqual([
"ec2",
"vpcs",
"vpc-1",
])
})
it("ec2SubnetsQueryOptions has correct key", () => {
expect(ec2SubnetsQueryOptions.queryKey).toEqual(["ec2", "subnets"])
})
it("ec2SubnetQueryOptions includes subnetId", () => {
expect(ec2SubnetQueryOptions("subnet-1").queryKey).toEqual([
"ec2",
"subnets",
"subnet-1",
])
})
it("ec2InstanceTypesQueryOptions has correct key", () => {
expect(ec2InstanceTypesQueryOptions.queryKey).toEqual([
"ec2",
"instances",
"types",
])
})
})
describe("staleTime and refetchInterval", () => {
it("availability zones use staleTime", () => {
expect(ec2AvailabilityZonesQueryOptions.staleTime).toBe(300_000)
})
it("regions use staleTime", () => {
expect(ec2RegionsQueryOptions.staleTime).toBe(300_000)
})
it("instances refetch on interval", () => {
expect(ec2InstancesQueryOptions.refetchInterval).toBe(5000)
})
it("volumes refetch on interval", () => {
expect(ec2VolumesQueryOptions.refetchInterval).toBe(5000)
})
it("snapshots refetch on interval", () => {
expect(ec2SnapshotsQueryOptions.refetchInterval).toBe(5000)
})
it("instance types refetch on interval", () => {
expect(ec2InstanceTypesQueryOptions.refetchInterval).toBe(5000)
})
})
describe("queryFn", () => {
it("ec2ImageQueryOptions returns empty result for undefined imageId", async () => {
const options = ec2ImageQueryOptions(undefined)
const queryFn = options.queryFn as (ctx: never) => Promise<unknown>
const result = await queryFn({} as never)
expect(result).toEqual({ Images: [], $metadata: {} })
expect(mockSend).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,77 @@
import { screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, expect, it, vi } from "vitest"
import { renderWithProviders } from "@/test/utils"
import { InstanceActions } from "./instance-actions"
const TRANSITIONING_RE = /Actions will be available/
const TERMINATED_RE = /terminated and cannot be managed/
// Mock mutation hooks
const mockMutate = vi.fn()
const defaultMutation = { mutate: mockMutate, isPending: false, error: null }
vi.mock("@/mutations/ec2", () => ({
useStartInstance: () => ({ ...defaultMutation }),
useStopInstance: () => ({ ...defaultMutation }),
useRebootInstance: () => ({ ...defaultMutation }),
useTerminateInstance: () => ({ ...defaultMutation }),
}))
describe("InstanceActions", () => {
describe("running instance", () => {
it("shows Stop, Reboot, and Terminate buttons", () => {
renderWithProviders(
<InstanceActions instanceId="i-123" state="running" />,
)
expect(screen.getByText("Stop")).toBeInTheDocument()
expect(screen.getByText("Reboot")).toBeInTheDocument()
expect(screen.getByText("Terminate")).toBeInTheDocument()
expect(screen.queryByText("Start")).not.toBeInTheDocument()
})
it("calls stop mutation on click", async () => {
const user = userEvent.setup()
renderWithProviders(
<InstanceActions instanceId="i-123" state="running" />,
)
await user.click(screen.getByText("Stop"))
expect(mockMutate).toHaveBeenCalledWith("i-123")
})
})
describe("stopped instance", () => {
it("shows Start and Terminate buttons", () => {
renderWithProviders(
<InstanceActions instanceId="i-123" state="stopped" />,
)
expect(screen.getByText("Start")).toBeInTheDocument()
expect(screen.getByText("Terminate")).toBeInTheDocument()
expect(screen.queryByText("Stop")).not.toBeInTheDocument()
expect(screen.queryByText("Reboot")).not.toBeInTheDocument()
})
})
describe("transitioning states", () => {
it.each([
"pending",
"stopping",
"shutting-down",
])("shows transitioning message for '%s' state", (state) => {
renderWithProviders(<InstanceActions instanceId="i-123" state={state} />)
expect(screen.getByText(TRANSITIONING_RE)).toBeInTheDocument()
expect(screen.queryByText("Start")).not.toBeInTheDocument()
expect(screen.queryByText("Stop")).not.toBeInTheDocument()
})
})
describe("terminated instance", () => {
it("shows terminated message", () => {
renderWithProviders(
<InstanceActions instanceId="i-123" state="terminated" />,
)
expect(screen.getByText(TERMINATED_RE)).toBeInTheDocument()
})
})
})
@@ -1,5 +1,6 @@
import { cleanup } from "@testing-library/react"
import "@testing-library/jest-dom/vitest"
import { afterEach } from "vitest"
afterEach(() => {
@@ -0,0 +1,26 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { type RenderOptions, render } from "@testing-library/react"
import type { ReactElement, ReactNode } from "react"
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
}
function TestProviders({ children }: { children: ReactNode }) {
const queryClient = createTestQueryClient()
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">,
) {
return render(ui, { wrapper: TestProviders, ...options })
}
@@ -0,0 +1,257 @@
import { describe, expect, it } from "vitest"
import {
attachVolumeSchema,
copySnapshotSchema,
createInstanceSchema,
createKeyPairSchema,
createSnapshotSchema,
createSubnetSchema,
createVolumeSchema,
createVpcSchema,
importKeyPairSchema,
modifyVolumeSchema,
} from "./ec2"
describe("createInstanceSchema", () => {
it("accepts valid instance params", () => {
const result = createInstanceSchema.safeParse({
imageId: "ami-123",
instanceType: "t2.micro",
keyName: "my-key",
count: 1,
})
expect(result.success).toBe(true)
})
it("requires count to be at least 1", () => {
const result = createInstanceSchema.safeParse({
imageId: "ami-123",
instanceType: "t2.micro",
keyName: "my-key",
count: 0,
})
expect(result.success).toBe(false)
})
it("requires count to be an integer", () => {
const result = createInstanceSchema.safeParse({
imageId: "ami-123",
instanceType: "t2.micro",
keyName: "my-key",
count: 1.5,
})
expect(result.success).toBe(false)
})
it("allows optional subnetId", () => {
const result = createInstanceSchema.safeParse({
imageId: "ami-123",
instanceType: "t2.micro",
keyName: "my-key",
count: 1,
subnetId: "subnet-abc",
})
expect(result.success).toBe(true)
})
it("supports capacity refine", () => {
const refined = createInstanceSchema.refine((data) => data.count <= 3, {
message: "Cannot exceed available capacity",
path: ["count"],
})
const result = refined.safeParse({
imageId: "ami-123",
instanceType: "t2.micro",
keyName: "my-key",
count: 5,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
"Cannot exceed available capacity",
)
}
})
})
describe("createKeyPairSchema", () => {
it("accepts a valid key name", () => {
const result = createKeyPairSchema.safeParse({ keyName: "my-key" })
expect(result.success).toBe(true)
})
it("rejects empty key name", () => {
const result = createKeyPairSchema.safeParse({ keyName: "" })
expect(result.success).toBe(false)
})
it("rejects key name over 255 chars", () => {
const result = createKeyPairSchema.safeParse({ keyName: "a".repeat(256) })
expect(result.success).toBe(false)
})
})
describe("importKeyPairSchema", () => {
it("accepts valid key pair import", () => {
const result = importKeyPairSchema.safeParse({
keyName: "my-key",
publicKeyMaterial: "ssh-rsa AAAAB3Nza...",
})
expect(result.success).toBe(true)
})
it("rejects empty public key", () => {
const result = importKeyPairSchema.safeParse({
keyName: "my-key",
publicKeyMaterial: "",
})
expect(result.success).toBe(false)
})
it("rejects whitespace-only public key", () => {
const result = importKeyPairSchema.safeParse({
keyName: "my-key",
publicKeyMaterial: " ",
})
expect(result.success).toBe(false)
})
})
describe("createVolumeSchema", () => {
it("accepts valid volume params", () => {
const result = createVolumeSchema.safeParse({
size: 10,
availabilityZone: "us-east-1a",
})
expect(result.success).toBe(true)
})
it("rejects size below 1", () => {
const result = createVolumeSchema.safeParse({
size: 0,
availabilityZone: "us-east-1a",
})
expect(result.success).toBe(false)
})
it("rejects size above 16384", () => {
const result = createVolumeSchema.safeParse({
size: 16_385,
availabilityZone: "us-east-1a",
})
expect(result.success).toBe(false)
})
it("rejects fractional size", () => {
const result = createVolumeSchema.safeParse({
size: 10.5,
availabilityZone: "us-east-1a",
})
expect(result.success).toBe(false)
})
})
describe("modifyVolumeSchema", () => {
it("accepts valid size", () => {
const result = modifyVolumeSchema.safeParse({ size: 20 })
expect(result.success).toBe(true)
})
it("rejects size below 1", () => {
const result = modifyVolumeSchema.safeParse({ size: 0 })
expect(result.success).toBe(false)
})
})
describe("createVpcSchema", () => {
it("accepts valid CIDR block", () => {
const result = createVpcSchema.safeParse({ cidrBlock: "10.0.0.0/16" })
expect(result.success).toBe(true)
})
it("accepts CIDR block with optional name", () => {
const result = createVpcSchema.safeParse({
cidrBlock: "10.0.0.0/16",
name: "my-vpc",
})
expect(result.success).toBe(true)
})
it("rejects invalid CIDR format", () => {
const result = createVpcSchema.safeParse({ cidrBlock: "not-a-cidr" })
expect(result.success).toBe(false)
})
it("rejects empty CIDR block", () => {
const result = createVpcSchema.safeParse({ cidrBlock: "" })
expect(result.success).toBe(false)
})
})
describe("createSubnetSchema", () => {
it("accepts valid subnet params", () => {
const result = createSubnetSchema.safeParse({
vpcId: "vpc-123",
cidrBlock: "10.0.1.0/24",
})
expect(result.success).toBe(true)
})
it("rejects invalid CIDR block", () => {
const result = createSubnetSchema.safeParse({
vpcId: "vpc-123",
cidrBlock: "invalid",
})
expect(result.success).toBe(false)
})
it("allows optional availability zone", () => {
const result = createSubnetSchema.safeParse({
vpcId: "vpc-123",
cidrBlock: "10.0.1.0/24",
availabilityZone: "us-east-1a",
})
expect(result.success).toBe(true)
})
})
describe("createSnapshotSchema", () => {
it("accepts valid snapshot params", () => {
const result = createSnapshotSchema.safeParse({ volumeId: "vol-123" })
expect(result.success).toBe(true)
})
it("rejects empty volumeId", () => {
const result = createSnapshotSchema.safeParse({ volumeId: "" })
expect(result.success).toBe(false)
})
})
describe("copySnapshotSchema", () => {
it("accepts valid copy params", () => {
const result = copySnapshotSchema.safeParse({
sourceSnapshotId: "snap-123",
sourceRegion: "us-east-1",
})
expect(result.success).toBe(true)
})
})
describe("attachVolumeSchema", () => {
it("accepts valid attach params", () => {
const result = attachVolumeSchema.safeParse({
volumeId: "vol-123",
instanceId: "i-123",
})
expect(result.success).toBe(true)
})
it("rejects empty instanceId", () => {
const result = attachVolumeSchema.safeParse({
volumeId: "vol-123",
instanceId: "",
})
expect(result.success).toBe(false)
})
})