mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-21 11:46:37 +00:00
feat: ui tests
This commit is contained in:
@@ -42,5 +42,8 @@ jobs:
|
||||
- name: Run Biome Check
|
||||
run: pnpm check
|
||||
|
||||
- name: Run Tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Build Frontend
|
||||
run: pnpm build
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
+77
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user