test(mvp1): add owner invariants and media form coverage
This commit is contained in:
84
apps/admin/src/components/media/media-upload-form.test.tsx
Normal file
84
apps/admin/src/components/media/media-upload-form.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { MediaUploadForm } from "./media-upload-form"
|
||||
|
||||
describe("MediaUploadForm", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("updates accepted MIME list based on selected media type", () => {
|
||||
render(<MediaUploadForm />)
|
||||
|
||||
const fileInput = screen.getByLabelText("File") as HTMLInputElement
|
||||
const typeSelect = screen.getByLabelText("Type") as HTMLSelectElement
|
||||
|
||||
expect(fileInput.accept).toContain("image/jpeg")
|
||||
|
||||
fireEvent.change(typeSelect, { target: { value: "video" } })
|
||||
|
||||
expect(fileInput.accept).toContain("video/mp4")
|
||||
expect(fileInput.accept).not.toContain("image/jpeg")
|
||||
})
|
||||
|
||||
it("shows API error message when upload fails", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ message: "Invalid file type" }),
|
||||
} as Response)
|
||||
|
||||
render(<MediaUploadForm />)
|
||||
|
||||
const form = screen.getByRole("button", { name: "Upload media" }).closest("form")
|
||||
|
||||
if (!form) {
|
||||
throw new Error("Upload form not found")
|
||||
}
|
||||
|
||||
const fileInput = screen.getByLabelText("File") as HTMLInputElement
|
||||
fireEvent.change(fileInput, {
|
||||
target: {
|
||||
files: [new File(["x"], "demo.png", { type: "image/png" })],
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.submit(form)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Invalid file type")).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/media/upload",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("shows network error message when request throws", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down"))
|
||||
|
||||
render(<MediaUploadForm />)
|
||||
|
||||
const form = screen.getByRole("button", { name: "Upload media" }).closest("form")
|
||||
|
||||
if (!form) {
|
||||
throw new Error("Upload form not found")
|
||||
}
|
||||
|
||||
const fileInput = screen.getByLabelText("File") as HTMLInputElement
|
||||
fireEvent.change(fileInput, {
|
||||
target: {
|
||||
files: [new File(["x"], "demo.png", { type: "image/png" })],
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.submit(form)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Upload request failed. Please retry.")).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
238
apps/admin/src/lib/auth/server.test.ts
Normal file
238
apps/admin/src/lib/auth/server.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const { mockDb, mockIsAdminSelfRegistrationEnabled, mockAuth, mockAuthRouteHandlers } = vi.hoisted(
|
||||
() => {
|
||||
const mockDb = {
|
||||
user: {
|
||||
count: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}
|
||||
|
||||
return {
|
||||
mockDb,
|
||||
mockIsAdminSelfRegistrationEnabled: vi.fn(),
|
||||
mockAuth: {
|
||||
api: {
|
||||
getSession: vi.fn(),
|
||||
},
|
||||
$context: Promise.resolve({
|
||||
internalAdapter: {
|
||||
findUserByEmail: vi.fn(),
|
||||
linkAccount: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
},
|
||||
password: {
|
||||
hash: vi.fn(async (value: string) => `hashed:${value}`),
|
||||
},
|
||||
}),
|
||||
},
|
||||
mockAuthRouteHandlers: {
|
||||
GET: vi.fn(),
|
||||
POST: vi.fn(),
|
||||
PATCH: vi.fn(),
|
||||
PUT: vi.fn(),
|
||||
DELETE: vi.fn(),
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
vi.mock("@cms/db", () => ({
|
||||
db: mockDb,
|
||||
isAdminSelfRegistrationEnabled: mockIsAdminSelfRegistrationEnabled,
|
||||
}))
|
||||
|
||||
vi.mock("better-auth", () => ({
|
||||
betterAuth: vi.fn(() => mockAuth),
|
||||
}))
|
||||
|
||||
vi.mock("better-auth/adapters/prisma", () => ({
|
||||
prismaAdapter: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock("better-auth/next-js", () => ({
|
||||
toNextJsHandler: vi.fn(() => mockAuthRouteHandlers),
|
||||
}))
|
||||
|
||||
import {
|
||||
canDeleteUserAccount,
|
||||
enforceOwnerInvariant,
|
||||
promoteFirstRegisteredUserToOwner,
|
||||
} from "./server"
|
||||
|
||||
describe("auth owner/support invariants", () => {
|
||||
beforeEach(() => {
|
||||
mockIsAdminSelfRegistrationEnabled.mockReset()
|
||||
mockDb.user.count.mockReset()
|
||||
mockDb.user.findUnique.mockReset()
|
||||
mockDb.user.findMany.mockReset()
|
||||
mockDb.user.findFirst.mockReset()
|
||||
mockDb.user.update.mockReset()
|
||||
mockDb.user.updateMany.mockReset()
|
||||
mockDb.$transaction.mockReset()
|
||||
})
|
||||
|
||||
it("blocks deletion of protected users", async () => {
|
||||
mockDb.user.findUnique.mockResolvedValue({
|
||||
role: "support",
|
||||
isProtected: true,
|
||||
})
|
||||
|
||||
const allowed = await canDeleteUserAccount("user-protected")
|
||||
|
||||
expect(allowed).toBe(false)
|
||||
})
|
||||
|
||||
it("allows deletion of non-owner non-protected users", async () => {
|
||||
mockDb.user.findUnique.mockResolvedValue({
|
||||
role: "editor",
|
||||
isProtected: false,
|
||||
})
|
||||
|
||||
const allowed = await canDeleteUserAccount("user-editor")
|
||||
|
||||
expect(allowed).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps sole owner non-deletable", async () => {
|
||||
mockDb.user.findUnique.mockResolvedValue({
|
||||
role: "owner",
|
||||
isProtected: false,
|
||||
})
|
||||
mockDb.user.count.mockResolvedValue(1)
|
||||
|
||||
const allowed = await canDeleteUserAccount("owner-1")
|
||||
|
||||
expect(allowed).toBe(false)
|
||||
})
|
||||
|
||||
it("promotes earliest non-support user when no owner exists", async () => {
|
||||
const tx = {
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "candidate-1" }),
|
||||
update: vi.fn().mockResolvedValue({ id: "candidate-1" }),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
mockDb.$transaction.mockImplementation(async (callback: (trx: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
)
|
||||
|
||||
const result = await enforceOwnerInvariant()
|
||||
|
||||
expect(result).toEqual({
|
||||
ownerId: "candidate-1",
|
||||
ownerCount: 1,
|
||||
repaired: true,
|
||||
})
|
||||
expect(tx.user.update).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("demotes extra owners and repairs canonical owner protection", async () => {
|
||||
const tx = {
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "owner-a", isProtected: false, isBanned: true },
|
||||
{ id: "owner-b", isProtected: true, isBanned: false },
|
||||
]),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({ id: "owner-a" }),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
}
|
||||
|
||||
mockDb.$transaction.mockImplementation(async (callback: (trx: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
)
|
||||
|
||||
const result = await enforceOwnerInvariant()
|
||||
|
||||
expect(result).toEqual({
|
||||
ownerId: "owner-a",
|
||||
ownerCount: 1,
|
||||
repaired: true,
|
||||
})
|
||||
expect(tx.user.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["owner-b"] } },
|
||||
data: { role: "admin", isProtected: false },
|
||||
})
|
||||
expect(tx.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "owner-a" },
|
||||
data: { isProtected: true, isBanned: false },
|
||||
})
|
||||
})
|
||||
|
||||
it("does not promote first registration when an owner already exists", async () => {
|
||||
mockDb.$transaction.mockImplementationOnce(
|
||||
async (
|
||||
callback: (tx: {
|
||||
user: { findFirst: () => Promise<{ id: string }>; update: () => void }
|
||||
}) => unknown,
|
||||
) =>
|
||||
callback({
|
||||
user: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "owner-existing" }),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const promoted = await promoteFirstRegisteredUserToOwner("candidate")
|
||||
|
||||
expect(promoted).toBe(false)
|
||||
})
|
||||
|
||||
it("promotes first registration and re-enforces owner invariant", async () => {
|
||||
mockDb.$transaction
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
callback: (tx: {
|
||||
user: { findFirst: () => Promise<null>; update: () => Promise<{ id: string }> }
|
||||
}) => unknown,
|
||||
) =>
|
||||
callback({
|
||||
user: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
update: vi.fn().mockResolvedValue({ id: "candidate" }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
callback: (tx: {
|
||||
user: {
|
||||
findMany: () => Promise<
|
||||
Array<{ id: string; isProtected: boolean; isBanned: boolean }>
|
||||
>
|
||||
findFirst: () => void
|
||||
update: () => void
|
||||
updateMany: () => void
|
||||
}
|
||||
}) => unknown,
|
||||
) =>
|
||||
callback({
|
||||
user: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "candidate", isProtected: true, isBanned: false }]),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const promoted = await promoteFirstRegisteredUserToOwner("candidate")
|
||||
|
||||
expect(promoted).toBe(true)
|
||||
expect(mockDb.$transaction).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user