mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat: 80% real frontend coverage
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { DetailCard } from "./detail-card"
|
||||
|
||||
describe("DetailCard", () => {
|
||||
it("renders children", () => {
|
||||
render(<DetailCard>Card content</DetailCard>)
|
||||
expect(screen.getByText("Card content")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders header", () => {
|
||||
render(<DetailCard.Header>Section Title</DetailCard.Header>)
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Section Title" }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders content", () => {
|
||||
render(<DetailCard.Content>Inner content</DetailCard.Content>)
|
||||
expect(screen.getByText("Inner content")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("composes root, header, and content together", () => {
|
||||
render(
|
||||
<DetailCard>
|
||||
<DetailCard.Header>Title</DetailCard.Header>
|
||||
<DetailCard.Content>Body</DetailCard.Content>
|
||||
</DetailCard>,
|
||||
)
|
||||
expect(screen.getByRole("heading", { name: "Title" })).toBeInTheDocument()
|
||||
expect(screen.getByText("Body")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { DetailRow } from "./detail-row"
|
||||
|
||||
describe("DetailRow", () => {
|
||||
it("renders label and value", () => {
|
||||
render(<DetailRow label="Instance ID" value="i-abc123" />)
|
||||
expect(screen.getByText("Instance ID")).toBeInTheDocument()
|
||||
expect(screen.getByText("i-abc123")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders dash when value is undefined", () => {
|
||||
render(<DetailRow label="Subnet" />)
|
||||
expect(screen.getByText("—")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders dash when value is empty string", () => {
|
||||
render(<DetailRow label="Subnet" value="" />)
|
||||
expect(screen.getByText("—")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders ReactNode as value", () => {
|
||||
render(<DetailRow label="Status" value={<span>running</span>} />)
|
||||
expect(screen.getByText("running")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { FormActions } from "./form-actions"
|
||||
|
||||
describe("FormActions", () => {
|
||||
it("renders submit and cancel buttons", () => {
|
||||
render(
|
||||
<FormActions
|
||||
isSubmitting={false}
|
||||
isPending={false}
|
||||
onCancel={vi.fn()}
|
||||
submitLabel="Create"
|
||||
pendingLabel="Creating..."
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole("button", { name: "Create" })).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows pending label when submitting", () => {
|
||||
render(
|
||||
<FormActions
|
||||
isSubmitting={true}
|
||||
isPending={false}
|
||||
onCancel={vi.fn()}
|
||||
submitLabel="Create"
|
||||
pendingLabel="Creating..."
|
||||
/>,
|
||||
)
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Creating..." }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows pending label when isPending", () => {
|
||||
render(
|
||||
<FormActions
|
||||
isSubmitting={false}
|
||||
isPending={true}
|
||||
onCancel={vi.fn()}
|
||||
submitLabel="Create"
|
||||
pendingLabel="Creating..."
|
||||
/>,
|
||||
)
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Creating..." }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("disables both buttons when submitting", () => {
|
||||
render(
|
||||
<FormActions
|
||||
isSubmitting={true}
|
||||
isPending={false}
|
||||
onCancel={vi.fn()}
|
||||
submitLabel="Create"
|
||||
pendingLabel="Creating..."
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole("button", { name: "Creating..." })).toBeDisabled()
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled()
|
||||
})
|
||||
|
||||
it("calls onCancel when cancel is clicked", async () => {
|
||||
const onCancel = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<FormActions
|
||||
isSubmitting={false}
|
||||
isPending={false}
|
||||
onCancel={onCancel}
|
||||
submitLabel="Create"
|
||||
pendingLabel="Creating..."
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }))
|
||||
expect(onCancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { act, render, renderHook, screen } from "@testing-library/react"
|
||||
import type { ReactNode } from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { ThemeProvider, useTheme } from "./theme-provider"
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <ThemeProvider defaultTheme="light">{children}</ThemeProvider>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.classList.remove("light", "dark")
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: false }),
|
||||
})
|
||||
})
|
||||
|
||||
describe("ThemeProvider", () => {
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<p>Hello</p>
|
||||
</ThemeProvider>,
|
||||
)
|
||||
expect(screen.getByText("Hello")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("applies light class to documentElement", () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="light">
|
||||
<p>Test</p>
|
||||
</ThemeProvider>,
|
||||
)
|
||||
expect(document.documentElement.classList.contains("light")).toBe(true)
|
||||
})
|
||||
|
||||
it("applies dark class to documentElement", () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark">
|
||||
<p>Test</p>
|
||||
</ThemeProvider>,
|
||||
)
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true)
|
||||
})
|
||||
|
||||
it("uses system theme when set to system", () => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: true }),
|
||||
})
|
||||
render(
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<p>Test</p>
|
||||
</ThemeProvider>,
|
||||
)
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true)
|
||||
})
|
||||
|
||||
it("reads stored theme from localStorage", () => {
|
||||
localStorage.setItem("ui-theme", "dark")
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<p>Test</p>
|
||||
</ThemeProvider>,
|
||||
)
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true)
|
||||
})
|
||||
|
||||
it("ignores invalid stored theme", () => {
|
||||
localStorage.setItem("ui-theme", "invalid")
|
||||
render(
|
||||
<ThemeProvider defaultTheme="light">
|
||||
<p>Test</p>
|
||||
</ThemeProvider>,
|
||||
)
|
||||
expect(document.documentElement.classList.contains("light")).toBe(true)
|
||||
})
|
||||
|
||||
it("uses custom storageKey", () => {
|
||||
localStorage.setItem("custom-key", "dark")
|
||||
render(
|
||||
<ThemeProvider storageKey="custom-key">
|
||||
<p>Test</p>
|
||||
</ThemeProvider>,
|
||||
)
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("useTheme", () => {
|
||||
it("throws when used outside ThemeProvider", () => {
|
||||
expect(() => renderHook(() => useTheme())).toThrow(
|
||||
"useTheme must be used within a ThemeProvider",
|
||||
)
|
||||
})
|
||||
|
||||
it("returns current theme", () => {
|
||||
const { result } = renderHook(() => useTheme(), { wrapper })
|
||||
expect(result.current.theme).toBe("light")
|
||||
})
|
||||
|
||||
it("setTheme updates theme and localStorage", () => {
|
||||
const { result } = renderHook(() => useTheme(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.setTheme("dark")
|
||||
})
|
||||
|
||||
expect(result.current.theme).toBe("dark")
|
||||
expect(localStorage.getItem("ui-theme")).toBe("dark")
|
||||
})
|
||||
|
||||
it("removes previous class when theme changes", () => {
|
||||
const { result } = renderHook(() => useTheme(), { wrapper })
|
||||
expect(document.documentElement.classList.contains("light")).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.setTheme("dark")
|
||||
})
|
||||
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true)
|
||||
expect(document.documentElement.classList.contains("light")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import type { ReactNode } from "react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { ThemeProvider, useTheme } from "./theme-provider"
|
||||
import { ThemeSwitch } from "./theme-switch"
|
||||
|
||||
function ThemeDisplay() {
|
||||
const { theme } = useTheme()
|
||||
return <span data-testid="theme">{theme}</span>
|
||||
}
|
||||
|
||||
function renderWithTheme(defaultTheme: "light" | "dark" | "system" = "light") {
|
||||
return render(
|
||||
<ThemeProvider defaultTheme={defaultTheme}>
|
||||
<ThemeSwitch />
|
||||
<ThemeDisplay />
|
||||
</ThemeProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe("ThemeSwitch", () => {
|
||||
it("renders toggle button", () => {
|
||||
renderWithTheme()
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Toggle theme" }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("toggles from light to dark", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithTheme("light")
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Toggle theme" }))
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("dark")
|
||||
})
|
||||
|
||||
it("toggles from dark to light", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithTheme("dark")
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Toggle theme" }))
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("light")
|
||||
})
|
||||
|
||||
it("toggles from system to dark", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithTheme("system")
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Toggle theme" }))
|
||||
expect(screen.getByTestId("theme")).toHaveTextContent("dark")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,269 @@
|
||||
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", () => ({
|
||||
getIamClient: () => ({ send: mockSend }),
|
||||
}))
|
||||
|
||||
import {
|
||||
useAttachUserPolicy,
|
||||
useCreateAccessKey,
|
||||
useCreatePolicy,
|
||||
useCreateUser,
|
||||
useDeleteAccessKey,
|
||||
useDeletePolicy,
|
||||
useDeleteUser,
|
||||
useDetachUserPolicy,
|
||||
useUpdateAccessKey,
|
||||
} from "./iam"
|
||||
|
||||
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("useCreateUser", () => {
|
||||
it("sends CreateUserCommand with userName", async () => {
|
||||
createQueryClient()
|
||||
const { result } = renderHook(() => useCreateUser(), { wrapper })
|
||||
|
||||
result.current.mutate({ userName: "admin" })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
UserName: "admin",
|
||||
Path: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("includes Path when provided", async () => {
|
||||
createQueryClient()
|
||||
const { result } = renderHook(() => useCreateUser(), { wrapper })
|
||||
|
||||
result.current.mutate({ userName: "admin", path: "/engineering/" })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
UserName: "admin",
|
||||
Path: "/engineering/",
|
||||
})
|
||||
})
|
||||
|
||||
it("invalidates users query on success", async () => {
|
||||
createQueryClient()
|
||||
const spy = vi.spyOn(queryClient, "invalidateQueries")
|
||||
const { result } = renderHook(() => useCreateUser(), { wrapper })
|
||||
|
||||
result.current.mutate({ userName: "admin" })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(spy).toHaveBeenCalledWith({ queryKey: ["iam", "users"] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("useDeleteUser", () => {
|
||||
it("sends DeleteUserCommand with userName", async () => {
|
||||
createQueryClient()
|
||||
const { result } = renderHook(() => useDeleteUser(), { wrapper })
|
||||
|
||||
result.current.mutate("admin")
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({ UserName: "admin" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("useCreateAccessKey", () => {
|
||||
it("sends CreateAccessKeyCommand with userName", async () => {
|
||||
createQueryClient()
|
||||
const { result } = renderHook(() => useCreateAccessKey(), { wrapper })
|
||||
|
||||
result.current.mutate("admin")
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({ UserName: "admin" })
|
||||
})
|
||||
|
||||
it("invalidates access-keys query on success", async () => {
|
||||
createQueryClient()
|
||||
const spy = vi.spyOn(queryClient, "invalidateQueries")
|
||||
const { result } = renderHook(() => useCreateAccessKey(), { wrapper })
|
||||
|
||||
result.current.mutate("admin")
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(spy).toHaveBeenCalledWith({ queryKey: ["iam", "access-keys"] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("useDeleteAccessKey", () => {
|
||||
it("sends DeleteAccessKeyCommand with userName and accessKeyId", async () => {
|
||||
createQueryClient()
|
||||
const { result } = renderHook(() => useDeleteAccessKey(), { wrapper })
|
||||
|
||||
result.current.mutate({ userName: "admin", accessKeyId: "AKIA123" })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
UserName: "admin",
|
||||
AccessKeyId: "AKIA123",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("useUpdateAccessKey", () => {
|
||||
it("sends UpdateAccessKeyCommand with status", async () => {
|
||||
createQueryClient()
|
||||
const { result } = renderHook(() => useUpdateAccessKey(), { wrapper })
|
||||
|
||||
result.current.mutate({
|
||||
userName: "admin",
|
||||
accessKeyId: "AKIA123",
|
||||
status: "Inactive",
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
UserName: "admin",
|
||||
AccessKeyId: "AKIA123",
|
||||
Status: "Inactive",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("useCreatePolicy", () => {
|
||||
it("sends CreatePolicyCommand with policy data", async () => {
|
||||
createQueryClient()
|
||||
const { result } = renderHook(() => useCreatePolicy(), { wrapper })
|
||||
|
||||
result.current.mutate({
|
||||
policyName: "ReadOnly",
|
||||
policyDocument: '{"Version":"2012-10-17"}',
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
PolicyName: "ReadOnly",
|
||||
Description: undefined,
|
||||
PolicyDocument: '{"Version":"2012-10-17"}',
|
||||
})
|
||||
})
|
||||
|
||||
it("includes Description when provided", async () => {
|
||||
createQueryClient()
|
||||
const { result } = renderHook(() => useCreatePolicy(), { wrapper })
|
||||
|
||||
result.current.mutate({
|
||||
policyName: "ReadOnly",
|
||||
description: "Read-only access",
|
||||
policyDocument: "{}",
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockSend.mock.calls[0]?.[0].input.Description).toBe(
|
||||
"Read-only access",
|
||||
)
|
||||
})
|
||||
|
||||
it("invalidates policies query on success", async () => {
|
||||
createQueryClient()
|
||||
const spy = vi.spyOn(queryClient, "invalidateQueries")
|
||||
const { result } = renderHook(() => useCreatePolicy(), { wrapper })
|
||||
|
||||
result.current.mutate({ policyName: "ReadOnly", policyDocument: "{}" })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(spy).toHaveBeenCalledWith({ queryKey: ["iam", "policies"] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("useDeletePolicy", () => {
|
||||
it("sends DeletePolicyCommand with policyArn", async () => {
|
||||
createQueryClient()
|
||||
const { result } = renderHook(() => useDeletePolicy(), { wrapper })
|
||||
|
||||
result.current.mutate("arn:aws:iam::123:policy/ReadOnly")
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
PolicyArn: "arn:aws:iam::123:policy/ReadOnly",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("useAttachUserPolicy", () => {
|
||||
it("sends AttachUserPolicyCommand with userName and policyArn", async () => {
|
||||
createQueryClient()
|
||||
const { result } = renderHook(() => useAttachUserPolicy(), { wrapper })
|
||||
|
||||
result.current.mutate({ userName: "admin", policyArn: "arn:test" })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
UserName: "admin",
|
||||
PolicyArn: "arn:test",
|
||||
})
|
||||
})
|
||||
|
||||
it("invalidates attached-user-policies query on success", async () => {
|
||||
createQueryClient()
|
||||
const spy = vi.spyOn(queryClient, "invalidateQueries")
|
||||
const { result } = renderHook(() => useAttachUserPolicy(), { wrapper })
|
||||
|
||||
result.current.mutate({ userName: "admin", policyArn: "arn:test" })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
queryKey: ["iam", "attached-user-policies"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("useDetachUserPolicy", () => {
|
||||
it("sends DetachUserPolicyCommand with userName and policyArn", async () => {
|
||||
createQueryClient()
|
||||
const { result } = renderHook(() => useDetachUserPolicy(), { wrapper })
|
||||
|
||||
result.current.mutate({ userName: "admin", policyArn: "arn:test" })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
UserName: "admin",
|
||||
PolicyArn: "arn:test",
|
||||
})
|
||||
})
|
||||
|
||||
it("invalidates attached-user-policies query on success", async () => {
|
||||
createQueryClient()
|
||||
const spy = vi.spyOn(queryClient, "invalidateQueries")
|
||||
const { result } = renderHook(() => useDetachUserPolicy(), { wrapper })
|
||||
|
||||
result.current.mutate({ userName: "admin", policyArn: "arn:test" })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
queryKey: ["iam", "attached-user-policies"],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mockSend = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock("@/lib/awsClient", () => ({
|
||||
getIamClient: () => ({ send: mockSend }),
|
||||
}))
|
||||
|
||||
import {
|
||||
iamAccessKeysQueryOptions,
|
||||
iamAttachedUserPoliciesQueryOptions,
|
||||
iamPoliciesQueryOptions,
|
||||
iamPolicyQueryOptions,
|
||||
iamPolicyVersionQueryOptions,
|
||||
iamUserQueryOptions,
|
||||
iamUsersQueryOptions,
|
||||
} from "./iam"
|
||||
|
||||
describe("query keys", () => {
|
||||
it("iamUsersQueryOptions has correct key", () => {
|
||||
expect(iamUsersQueryOptions.queryKey).toEqual(["iam", "users"])
|
||||
})
|
||||
|
||||
it("iamUserQueryOptions includes userName in key", () => {
|
||||
expect(iamUserQueryOptions("admin").queryKey).toEqual([
|
||||
"iam",
|
||||
"users",
|
||||
"admin",
|
||||
])
|
||||
})
|
||||
|
||||
it("iamAccessKeysQueryOptions includes userName in key", () => {
|
||||
expect(iamAccessKeysQueryOptions("admin").queryKey).toEqual([
|
||||
"iam",
|
||||
"access-keys",
|
||||
"admin",
|
||||
])
|
||||
})
|
||||
|
||||
it("iamPoliciesQueryOptions has correct key", () => {
|
||||
expect(iamPoliciesQueryOptions.queryKey).toEqual(["iam", "policies"])
|
||||
})
|
||||
|
||||
it("iamPolicyQueryOptions includes policyArn in key", () => {
|
||||
expect(
|
||||
iamPolicyQueryOptions("arn:aws:iam::123:policy/ReadOnly").queryKey,
|
||||
).toEqual(["iam", "policies", "arn:aws:iam::123:policy/ReadOnly"])
|
||||
})
|
||||
|
||||
it("iamPolicyVersionQueryOptions includes policyArn and versionId in key", () => {
|
||||
expect(
|
||||
iamPolicyVersionQueryOptions("arn:aws:iam::123:policy/ReadOnly", "v1")
|
||||
.queryKey,
|
||||
).toEqual([
|
||||
"iam",
|
||||
"policy-versions",
|
||||
"arn:aws:iam::123:policy/ReadOnly",
|
||||
"v1",
|
||||
])
|
||||
})
|
||||
|
||||
it("iamAttachedUserPoliciesQueryOptions includes userName in key", () => {
|
||||
expect(iamAttachedUserPoliciesQueryOptions("admin").queryKey).toEqual([
|
||||
"iam",
|
||||
"attached-user-policies",
|
||||
"admin",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("staleTime", () => {
|
||||
it("users use staleTime", () => {
|
||||
expect(iamUsersQueryOptions.staleTime).toBe(300_000)
|
||||
})
|
||||
|
||||
it("user uses staleTime", () => {
|
||||
expect(iamUserQueryOptions("admin").staleTime).toBe(300_000)
|
||||
})
|
||||
|
||||
it("access keys use staleTime", () => {
|
||||
expect(iamAccessKeysQueryOptions("admin").staleTime).toBe(300_000)
|
||||
})
|
||||
|
||||
it("policies use staleTime", () => {
|
||||
expect(iamPoliciesQueryOptions.staleTime).toBe(300_000)
|
||||
})
|
||||
|
||||
it("policy uses staleTime", () => {
|
||||
expect(iamPolicyQueryOptions("arn:test").staleTime).toBe(300_000)
|
||||
})
|
||||
|
||||
it("policy version uses staleTime", () => {
|
||||
expect(iamPolicyVersionQueryOptions("arn:test", "v1").staleTime).toBe(
|
||||
300_000,
|
||||
)
|
||||
})
|
||||
|
||||
it("attached user policies use staleTime", () => {
|
||||
expect(iamAttachedUserPoliciesQueryOptions("admin").staleTime).toBe(
|
||||
300_000,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("queryFn", () => {
|
||||
afterEach(() => {
|
||||
mockSend.mockClear()
|
||||
})
|
||||
|
||||
it("iamUsersQueryOptions sends ListUsersCommand", async () => {
|
||||
const queryFn = iamUsersQueryOptions.queryFn as (
|
||||
ctx: never,
|
||||
) => Promise<unknown>
|
||||
await queryFn({} as never)
|
||||
expect(mockSend).toHaveBeenCalledOnce()
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({})
|
||||
})
|
||||
|
||||
it("iamUserQueryOptions sends GetUserCommand with userName", async () => {
|
||||
const queryFn = iamUserQueryOptions("admin").queryFn as (
|
||||
ctx: never,
|
||||
) => Promise<unknown>
|
||||
await queryFn({} as never)
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({ UserName: "admin" })
|
||||
})
|
||||
|
||||
it("iamAccessKeysQueryOptions sends ListAccessKeysCommand", async () => {
|
||||
const queryFn = iamAccessKeysQueryOptions("admin").queryFn as (
|
||||
ctx: never,
|
||||
) => Promise<unknown>
|
||||
await queryFn({} as never)
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({ UserName: "admin" })
|
||||
})
|
||||
|
||||
it("iamPoliciesQueryOptions sends ListPoliciesCommand with Local scope", async () => {
|
||||
const queryFn = iamPoliciesQueryOptions.queryFn as (
|
||||
ctx: never,
|
||||
) => Promise<unknown>
|
||||
await queryFn({} as never)
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({ Scope: "Local" })
|
||||
})
|
||||
|
||||
it("iamPolicyQueryOptions sends GetPolicyCommand with policyArn", async () => {
|
||||
const queryFn = iamPolicyQueryOptions("arn:test").queryFn as (
|
||||
ctx: never,
|
||||
) => Promise<unknown>
|
||||
await queryFn({} as never)
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
PolicyArn: "arn:test",
|
||||
})
|
||||
})
|
||||
|
||||
it("iamPolicyVersionQueryOptions sends GetPolicyVersionCommand", async () => {
|
||||
const queryFn = iamPolicyVersionQueryOptions("arn:test", "v1").queryFn as (
|
||||
ctx: never,
|
||||
) => Promise<unknown>
|
||||
await queryFn({} as never)
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
PolicyArn: "arn:test",
|
||||
VersionId: "v1",
|
||||
})
|
||||
})
|
||||
|
||||
it("iamAttachedUserPoliciesQueryOptions sends ListAttachedUserPoliciesCommand", async () => {
|
||||
const queryFn = iamAttachedUserPoliciesQueryOptions("admin").queryFn as (
|
||||
ctx: never,
|
||||
) => Promise<unknown>
|
||||
await queryFn({} as never)
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({ UserName: "admin" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mockSend = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock("@/lib/awsClient", () => ({
|
||||
getS3Client: () => ({ send: mockSend }),
|
||||
}))
|
||||
|
||||
import { s3BucketObjectsQueryOptions, s3BucketsQueryOptions } from "./s3"
|
||||
|
||||
describe("query keys", () => {
|
||||
it("s3BucketsQueryOptions has correct key", () => {
|
||||
expect(s3BucketsQueryOptions.queryKey).toEqual(["s3", "buckets"])
|
||||
})
|
||||
|
||||
it("s3BucketObjectsQueryOptions includes bucket and empty prefix in key", () => {
|
||||
expect(s3BucketObjectsQueryOptions("my-bucket").queryKey).toEqual([
|
||||
"s3",
|
||||
"buckets",
|
||||
"my-bucket",
|
||||
"objects",
|
||||
"",
|
||||
])
|
||||
})
|
||||
|
||||
it("s3BucketObjectsQueryOptions includes prefix in key", () => {
|
||||
expect(
|
||||
s3BucketObjectsQueryOptions("my-bucket", "photos/").queryKey,
|
||||
).toEqual(["s3", "buckets", "my-bucket", "objects", "photos/"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("queryFn", () => {
|
||||
afterEach(() => {
|
||||
mockSend.mockClear()
|
||||
})
|
||||
|
||||
it("s3BucketsQueryOptions sends ListBucketsCommand", async () => {
|
||||
const queryFn = s3BucketsQueryOptions.queryFn as (
|
||||
ctx: never,
|
||||
) => Promise<unknown>
|
||||
await queryFn({} as never)
|
||||
expect(mockSend).toHaveBeenCalledOnce()
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({})
|
||||
})
|
||||
|
||||
it("s3BucketObjectsQueryOptions sends ListObjectsV2Command with bucket and delimiter", async () => {
|
||||
const queryFn = s3BucketObjectsQueryOptions("my-bucket").queryFn as (
|
||||
ctx: never,
|
||||
) => Promise<unknown>
|
||||
await queryFn({} as never)
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
Bucket: "my-bucket",
|
||||
Prefix: undefined,
|
||||
Delimiter: "/",
|
||||
})
|
||||
})
|
||||
|
||||
it("s3BucketObjectsQueryOptions sends prefix when provided", async () => {
|
||||
const queryFn = s3BucketObjectsQueryOptions("my-bucket", "docs/")
|
||||
.queryFn as (ctx: never) => Promise<unknown>
|
||||
await queryFn({} as never)
|
||||
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
|
||||
Bucket: "my-bucket",
|
||||
Prefix: "docs/",
|
||||
Delimiter: "/",
|
||||
})
|
||||
})
|
||||
})
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { PolicyDocumentViewer } from "./policy-document-viewer"
|
||||
|
||||
describe("PolicyDocumentViewer", () => {
|
||||
it("formats valid JSON with indentation", () => {
|
||||
const doc = '{"Version":"2012-10-17","Statement":[]}'
|
||||
const { container } = render(<PolicyDocumentViewer document={doc} />)
|
||||
const pre = container.querySelector("pre")
|
||||
expect(pre?.textContent).toBe(JSON.stringify(JSON.parse(doc), null, 2))
|
||||
})
|
||||
|
||||
it("renders invalid JSON as-is", () => {
|
||||
render(<PolicyDocumentViewer document="not valid json" />)
|
||||
expect(screen.getByText("not valid json")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders in a pre element", () => {
|
||||
render(<PolicyDocumentViewer document="{}" />)
|
||||
const pre = screen.getByText("{}").closest("pre")
|
||||
expect(pre).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { createPolicySchema, createUserSchema } from "./iam"
|
||||
|
||||
describe("createUserSchema", () => {
|
||||
it("accepts a valid user name", () => {
|
||||
const result = createUserSchema.safeParse({ userName: "admin" })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects empty user name", () => {
|
||||
const result = createUserSchema.safeParse({ userName: "" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects user name over 64 chars", () => {
|
||||
const result = createUserSchema.safeParse({ userName: "a".repeat(65) })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects user name with invalid characters", () => {
|
||||
const result = createUserSchema.safeParse({ userName: "user name!" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("accepts user name with allowed special characters", () => {
|
||||
const result = createUserSchema.safeParse({ userName: "user+=,.@-test" })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("allows optional path", () => {
|
||||
const result = createUserSchema.safeParse({
|
||||
userName: "admin",
|
||||
path: "/engineering/",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createPolicySchema", () => {
|
||||
it("accepts valid policy params", () => {
|
||||
const result = createPolicySchema.safeParse({
|
||||
policyName: "ReadOnly",
|
||||
policyDocument: '{"Version":"2012-10-17","Statement":[]}',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects empty policy name", () => {
|
||||
const result = createPolicySchema.safeParse({
|
||||
policyName: "",
|
||||
policyDocument: "{}",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects policy name over 128 chars", () => {
|
||||
const result = createPolicySchema.safeParse({
|
||||
policyName: "a".repeat(129),
|
||||
policyDocument: "{}",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects invalid JSON in policy document", () => {
|
||||
const result = createPolicySchema.safeParse({
|
||||
policyName: "ReadOnly",
|
||||
policyDocument: "not json",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects empty policy document", () => {
|
||||
const result = createPolicySchema.safeParse({
|
||||
policyName: "ReadOnly",
|
||||
policyDocument: "",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("allows optional description", () => {
|
||||
const result = createPolicySchema.safeParse({
|
||||
policyName: "ReadOnly",
|
||||
description: "Read-only access",
|
||||
policyDocument: "{}",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { createBucketSchema } from "./s3"
|
||||
|
||||
describe("createBucketSchema", () => {
|
||||
it("accepts a valid bucket name", () => {
|
||||
const result = createBucketSchema.safeParse({ bucketName: "my-bucket" })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects bucket name under 3 characters", () => {
|
||||
const result = createBucketSchema.safeParse({ bucketName: "ab" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects bucket name over 63 characters", () => {
|
||||
const result = createBucketSchema.safeParse({
|
||||
bucketName: "a".repeat(64),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects uppercase letters", () => {
|
||||
const result = createBucketSchema.safeParse({ bucketName: "MyBucket" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects names starting with a period", () => {
|
||||
const result = createBucketSchema.safeParse({ bucketName: ".my-bucket" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects names ending with a hyphen", () => {
|
||||
const result = createBucketSchema.safeParse({ bucketName: "my-bucket-" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects consecutive periods", () => {
|
||||
const result = createBucketSchema.safeParse({ bucketName: "my..bucket" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects adjacent period and hyphen", () => {
|
||||
const result = createBucketSchema.safeParse({ bucketName: "my.-bucket" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects adjacent hyphen and period", () => {
|
||||
const result = createBucketSchema.safeParse({ bucketName: "my-.bucket" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("accepts bucket name with periods and hyphens", () => {
|
||||
const result = createBucketSchema.safeParse({
|
||||
bucketName: "my.test-bucket.v2",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("accepts all-numeric bucket name", () => {
|
||||
const result = createBucketSchema.safeParse({ bucketName: "123" })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -48,8 +48,20 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
coverage: {
|
||||
all: true,
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: [
|
||||
"src/components/ui/**",
|
||||
"src/layouts/**",
|
||||
"src/routes/*.{ts,tsx}",
|
||||
"src/routes/**/!(-components)/*.{ts,tsx}",
|
||||
"src/test/**",
|
||||
"src/**/*.test.*",
|
||||
"src/routeTree.gen.ts",
|
||||
"src/main.tsx",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 70,
|
||||
lines: 70,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user