mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-09-19 17:50:13 +00:00
feat: server transfer rotation
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
||||
|
||||
const toastCalls: Array<{ title: string; description: string }> = []
|
||||
vi.mock("sonner", () => ({
|
||||
toast: (title: string, opts?: { description?: string }) => {
|
||||
toastCalls.push({ title, description: opts?.description ?? "" })
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) => {
|
||||
if (vars && typeof vars.count === "number") {
|
||||
return `${key}=${vars.count}`
|
||||
}
|
||||
return key
|
||||
},
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: () => undefined },
|
||||
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
|
||||
}))
|
||||
|
||||
const batchMoveServer = vi.fn()
|
||||
vi.mock("@/api/server", () => ({
|
||||
batchMoveServer: (...args: unknown[]) => batchMoveServer(...args),
|
||||
}))
|
||||
|
||||
import { BatchMoveServerIcon } from "@/components/batch-move-server-icon"
|
||||
|
||||
beforeEach(() => {
|
||||
toastCalls.length = 0
|
||||
batchMoveServer.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ""
|
||||
})
|
||||
|
||||
async function openDialogAndSubmit(serverIds: number[], toUser: number) {
|
||||
render(<BatchMoveServerIcon serverIds={serverIds} />)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button"))
|
||||
})
|
||||
const userInput = await screen.findByPlaceholderText("User ID")
|
||||
await act(async () => {
|
||||
fireEvent.change(userInput, { target: { value: String(toUser) } })
|
||||
})
|
||||
const submit = screen.getByRole("button", { name: "Move" })
|
||||
await act(async () => {
|
||||
fireEvent.click(submit)
|
||||
})
|
||||
await waitFor(() => expect(toastCalls.length).toBeGreaterThan(0))
|
||||
}
|
||||
|
||||
test("BatchMoveServer toast surfaces agent_too_old count so operator sees the failure", async () => {
|
||||
batchMoveServer.mockResolvedValueOnce([
|
||||
{ server_id: 1, status: "agent_too_old", error: "agent build older than v1.18.0" },
|
||||
{ server_id: 2, status: "pending", transfer_id: 99 },
|
||||
])
|
||||
|
||||
await openDialogAndSubmit([1, 2], 300)
|
||||
|
||||
expect(batchMoveServer).toHaveBeenCalledOnce()
|
||||
const summary = toastCalls[toastCalls.length - 1]
|
||||
expect(summary, "submission must surface a toast").toBeDefined()
|
||||
expect(summary.description, "toast must include the agent_too_old count").toContain(
|
||||
"Transfer.AgentTooOldCount=1",
|
||||
)
|
||||
expect(summary.description).toContain("Transfer.PendingCount=1")
|
||||
})
|
||||
|
||||
test("BatchMoveServer toast does not show Done fallback when every server is agent_too_old", async () => {
|
||||
batchMoveServer.mockResolvedValueOnce([
|
||||
{ server_id: 1, status: "agent_too_old", error: "older than v1.18.0" },
|
||||
{ server_id: 2, status: "agent_too_old", error: "older than v1.18.0" },
|
||||
])
|
||||
|
||||
await openDialogAndSubmit([1, 2], 300)
|
||||
|
||||
const summary = toastCalls[toastCalls.length - 1]
|
||||
expect(summary).toBeDefined()
|
||||
expect(
|
||||
summary.description,
|
||||
"all-failed batch must NOT collapse to a generic Done label — operator would think it succeeded",
|
||||
).toContain("Transfer.AgentTooOldCount=2")
|
||||
expect(summary.description).not.toBe("Done")
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { act, render, waitFor } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
||||
|
||||
type DivProps = React.ComponentPropsWithoutRef<"div"> & { asChild?: boolean }
|
||||
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
|
||||
asChild?: boolean
|
||||
size?: string
|
||||
variant?: string
|
||||
}
|
||||
type IconButtonProps = ButtonProps & { icon?: string }
|
||||
type SentWebSocketData = string | ArrayBufferLike | Blob | ArrayBufferView
|
||||
|
||||
vi.mock("../components/ui/button", () => ({
|
||||
Button: (props: ButtonProps) => {
|
||||
const { asChild, size, variant, ...buttonProps } = props
|
||||
void asChild
|
||||
void size
|
||||
void variant
|
||||
return <button {...buttonProps} />
|
||||
},
|
||||
}))
|
||||
vi.mock("../components/ui/input", () => ({
|
||||
Input: (props: React.ComponentPropsWithoutRef<"input">) => <input {...props} />,
|
||||
}))
|
||||
vi.mock("../components/ui/table", () => ({
|
||||
Table: (props: React.ComponentPropsWithoutRef<"table">) => <table {...props} />,
|
||||
TableHeader: (props: React.ComponentPropsWithoutRef<"thead">) => <thead {...props} />,
|
||||
TableBody: (props: React.ComponentPropsWithoutRef<"tbody">) => <tbody {...props} />,
|
||||
TableRow: (props: React.ComponentPropsWithoutRef<"tr">) => <tr {...props} />,
|
||||
TableCell: (props: React.ComponentPropsWithoutRef<"td">) => <td {...props} />,
|
||||
TableHead: (props: React.ComponentPropsWithoutRef<"th">) => <th {...props} />,
|
||||
}))
|
||||
vi.mock("../components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: (props: DivProps) => <div {...props} />,
|
||||
DropdownMenuTrigger: (props: DivProps) => <div {...props} />,
|
||||
DropdownMenuContent: (props: DivProps) => <div {...props} />,
|
||||
DropdownMenuItem: (props: DivProps) => <div {...props} />,
|
||||
}))
|
||||
vi.mock("../components/ui/alert-dialog", () => ({
|
||||
AlertDialog: (props: DivProps) => <div {...props} />,
|
||||
AlertDialogTrigger: (props: DivProps) => <div {...props} />,
|
||||
AlertDialogContent: (props: DivProps) => <div {...props} />,
|
||||
AlertDialogHeader: (props: DivProps) => <div {...props} />,
|
||||
AlertDialogFooter: (props: DivProps) => <div {...props} />,
|
||||
AlertDialogTitle: (props: DivProps) => <div {...props} />,
|
||||
AlertDialogDescription: (props: DivProps) => <div {...props} />,
|
||||
AlertDialogCancel: (props: DivProps) => <div {...props} />,
|
||||
AlertDialogAction: (props: DivProps) => <div {...props} />,
|
||||
}))
|
||||
vi.mock("../components/ui/drawer", () => ({
|
||||
Drawer: (props: DivProps) => <div {...props} />,
|
||||
DrawerContent: (props: DivProps) => <div {...props} />,
|
||||
DrawerHeader: (props: DivProps) => <div {...props} />,
|
||||
DrawerTitle: (props: DivProps) => <div {...props} />,
|
||||
DrawerTrigger: (props: DivProps) => <div {...props} />,
|
||||
}))
|
||||
vi.mock("../components/xui/overlayless-sheet", () => ({
|
||||
Sheet: (props: DivProps) => <div {...props} />,
|
||||
SheetContent: (props: DivProps) => <div {...props} />,
|
||||
SheetDescription: (props: DivProps) => <div {...props} />,
|
||||
SheetHeader: (props: DivProps) => <div {...props} />,
|
||||
SheetTitle: (props: DivProps) => <div {...props} />,
|
||||
SheetTrigger: (props: DivProps) => <div {...props} />,
|
||||
}))
|
||||
vi.mock("../components/xui/filepath", () => ({
|
||||
Filepath: () => <div data-testid="filepath" />,
|
||||
}))
|
||||
vi.mock("../components/xui/icon-button", () => ({
|
||||
IconButton: (props: IconButtonProps) => {
|
||||
const { asChild, icon, size, variant, ...buttonProps } = props
|
||||
void asChild
|
||||
void icon
|
||||
void size
|
||||
void variant
|
||||
return <button {...buttonProps} />
|
||||
},
|
||||
}))
|
||||
vi.mock("../components/xui/virtulized-data-table", () => ({
|
||||
DataTable: () => <div data-testid="data-table" />,
|
||||
}))
|
||||
vi.mock("lucide-react", () => ({
|
||||
File: () => <div data-testid="file-icon" />,
|
||||
Folder: () => <div data-testid="folder-icon" />,
|
||||
}))
|
||||
|
||||
const translate = (key: string) => key
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: translate }),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}))
|
||||
|
||||
vi.mock("sonner", () => ({ toast: vi.fn() }))
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
fm: {
|
||||
parseFMList: async (buf: ArrayBufferLike) => {
|
||||
const view = new DataView(buf)
|
||||
const pathLength = view.getUint32(4, false)
|
||||
const pathBytes = new Uint8Array(buf, 8, pathLength)
|
||||
|
||||
return { path: new TextDecoder().decode(pathBytes), fmList: [] }
|
||||
},
|
||||
},
|
||||
fmWorker: new Worker(""),
|
||||
formatPath: (path: string) => path,
|
||||
}))
|
||||
|
||||
import { FMComponent } from "../components/fm"
|
||||
|
||||
const webSockets: MockWebSocket[] = []
|
||||
|
||||
class MockWebSocket extends EventTarget implements WebSocket {
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
static readonly CLOSING = 2
|
||||
static readonly CLOSED = 3
|
||||
|
||||
readonly CONNECTING = MockWebSocket.CONNECTING
|
||||
readonly OPEN = MockWebSocket.OPEN
|
||||
readonly CLOSING = MockWebSocket.CLOSING
|
||||
readonly CLOSED = MockWebSocket.CLOSED
|
||||
readonly bufferedAmount = 0
|
||||
readonly extensions = ""
|
||||
readonly protocol = ""
|
||||
readonly url: string
|
||||
|
||||
binaryType: BinaryType = "arraybuffer"
|
||||
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null
|
||||
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null
|
||||
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null
|
||||
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null
|
||||
readyState = MockWebSocket.CONNECTING
|
||||
sent: SentWebSocketData[] = []
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super()
|
||||
this.url = url.toString()
|
||||
webSockets.push(this)
|
||||
}
|
||||
|
||||
addEventListener<K extends keyof WebSocketEventMap>(
|
||||
type: K,
|
||||
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void {
|
||||
super.addEventListener(type, listener, options)
|
||||
}
|
||||
|
||||
removeEventListener<K extends keyof WebSocketEventMap>(
|
||||
type: K,
|
||||
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void {
|
||||
super.removeEventListener(type, listener, options)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = MockWebSocket.OPEN
|
||||
this.onopen?.call(this, new Event("open"))
|
||||
}
|
||||
|
||||
send(data: SentWebSocketData): void {
|
||||
this.sent.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
webSockets.length = 0
|
||||
globalThis.WebSocket = MockWebSocket
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const encodeFileNameMessage = (path: string) => {
|
||||
const identifier = new Uint8Array([0x4e, 0x5a, 0x46, 0x4e])
|
||||
const pathBytes = new TextEncoder().encode(path)
|
||||
const payload = new Uint8Array(identifier.length + 4 + pathBytes.length)
|
||||
payload.set(identifier, 0)
|
||||
new DataView(payload.buffer).setUint32(identifier.length, pathBytes.length, false)
|
||||
payload.set(pathBytes, identifier.length + 4)
|
||||
return payload.buffer
|
||||
}
|
||||
|
||||
const encodeCompleteMessage = () => new Uint8Array([0x4e, 0x5a, 0x55, 0x50]).buffer
|
||||
|
||||
const decodeListPath = (data: SentWebSocketData) => {
|
||||
if (!ArrayBuffer.isView(data)) return null
|
||||
|
||||
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
if (bytes[0] !== 0) return null
|
||||
|
||||
return new TextDecoder().decode(bytes.slice(1))
|
||||
}
|
||||
|
||||
test("FM websocket lifecycle reuses the socket on path changes", async () => {
|
||||
const { unmount } = render(<FMComponent wsUrl="/ws/file/test" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(webSockets).toHaveLength(1)
|
||||
})
|
||||
|
||||
const socket = webSockets[0]
|
||||
act(() => {
|
||||
socket.open()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await socket.onmessage?.call(
|
||||
socket,
|
||||
new MessageEvent("message", { data: encodeFileNameMessage("/new/path") }),
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(webSockets).toHaveLength(1)
|
||||
expect(decodeListPath(socket.sent[socket.sent.length - 1])).toBe("/new/path")
|
||||
})
|
||||
|
||||
socket.sent.length = 0
|
||||
await act(async () => {
|
||||
await socket.onmessage?.call(socket, new MessageEvent("message", { data: encodeCompleteMessage() }))
|
||||
})
|
||||
|
||||
expect(webSockets).toHaveLength(1)
|
||||
expect(decodeListPath(socket.sent[socket.sent.length - 1])).toBe("/new/path")
|
||||
|
||||
unmount()
|
||||
expect(socket.readyState).toBe(MockWebSocket.CLOSED)
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
class TestWorker implements Worker {
|
||||
onmessage: ((this: Worker, ev: MessageEvent) => unknown) | null = null
|
||||
onmessageerror: ((this: Worker, ev: MessageEvent) => unknown) | null = null
|
||||
onerror: ((this: AbstractWorker, ev: ErrorEvent) => unknown) | null = null
|
||||
|
||||
addEventListener(): void {}
|
||||
removeEventListener(): void {}
|
||||
dispatchEvent(): boolean {
|
||||
return true
|
||||
}
|
||||
postMessage(): void {}
|
||||
terminate(): void {}
|
||||
}
|
||||
|
||||
class TestResizeObserver implements ResizeObserver {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
class TestIntersectionObserver implements IntersectionObserver {
|
||||
readonly root: Element | Document | null = null
|
||||
readonly rootMargin = ""
|
||||
readonly thresholds: ReadonlyArray<number> = []
|
||||
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.Worker = TestWorker
|
||||
globalThis.ResizeObserver = TestResizeObserver
|
||||
globalThis.IntersectionObserver = TestIntersectionObserver
|
||||
@@ -0,0 +1,103 @@
|
||||
import { render } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
||||
|
||||
vi.mock("sonner", () => ({ toast: () => undefined }))
|
||||
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
sleep: () => Promise.resolve(),
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(" "),
|
||||
}))
|
||||
|
||||
const attachAddonInstances: { ws: WebSocket }[] = []
|
||||
vi.mock("@xterm/addon-attach", () => ({
|
||||
AttachAddon: class {
|
||||
ws: WebSocket
|
||||
constructor(ws: WebSocket) {
|
||||
this.ws = ws
|
||||
attachAddonInstances.push(this)
|
||||
}
|
||||
activate() {}
|
||||
dispose() {}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: class {
|
||||
activate() {}
|
||||
dispose() {}
|
||||
fit() {}
|
||||
proposeDimensions() {
|
||||
return { rows: 24, cols: 80 }
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: class {
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
dispose() {}
|
||||
},
|
||||
}))
|
||||
|
||||
class FakeWebSocket {
|
||||
static instances: FakeWebSocket[] = []
|
||||
url: string
|
||||
binaryType = "arraybuffer"
|
||||
onopen: ((ev: Event) => unknown) | null = null
|
||||
onclose: ((ev: Event) => unknown) | null = null
|
||||
onerror: ((ev: Event) => unknown) | null = null
|
||||
onmessage: ((ev: MessageEvent) => unknown) | null = null
|
||||
readyState = 0
|
||||
closeCalls = 0
|
||||
|
||||
constructor(url: string | URL) {
|
||||
this.url = url.toString()
|
||||
FakeWebSocket.instances.push(this)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closeCalls += 1
|
||||
this.readyState = 3
|
||||
}
|
||||
|
||||
send() {}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
dispatchEvent() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.instances = []
|
||||
attachAddonInstances.length = 0
|
||||
;(globalThis as { WebSocket: typeof WebSocket }).WebSocket =
|
||||
FakeWebSocket as unknown as typeof WebSocket
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test("XtermComponent closes the previous WebSocket and re-attaches xterm when wsUrl changes", async () => {
|
||||
const { XtermComponent } = await import("../components/terminal")
|
||||
const noop = () => undefined
|
||||
|
||||
const { rerender } = render(
|
||||
<XtermComponent wsUrl="/api/v1/ws/terminal/session-1" setClose={noop} />,
|
||||
)
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
const firstSocket = FakeWebSocket.instances[0]
|
||||
expect(attachAddonInstances).toHaveLength(1)
|
||||
expect(attachAddonInstances[0].ws).toBe(firstSocket as unknown as WebSocket)
|
||||
|
||||
rerender(<XtermComponent wsUrl="/api/v1/ws/terminal/session-2" setClose={noop} />)
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(2)
|
||||
const secondSocket = FakeWebSocket.instances[1]
|
||||
expect(firstSocket.closeCalls).toBeGreaterThanOrEqual(1)
|
||||
expect(attachAddonInstances).toHaveLength(2)
|
||||
expect(attachAddonInstances[1].ws).toBe(secondSocket as unknown as WebSocket)
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest"
|
||||
|
||||
const toastCalls: Array<{ title: string; description: string }> = []
|
||||
vi.mock("sonner", () => ({
|
||||
toast: (title: string, opts?: { description?: string }) => {
|
||||
toastCalls.push({ title, description: opts?.description ?? "" })
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: () => undefined },
|
||||
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
|
||||
}))
|
||||
|
||||
const cancelServerTransfer = vi.fn()
|
||||
const retryServerTransfer = vi.fn()
|
||||
vi.mock("@/api/transfer", () => ({
|
||||
cancelServerTransfer: (...args: unknown[]) => cancelServerTransfer(...args),
|
||||
retryServerTransfer: (...args: unknown[]) => retryServerTransfer(...args),
|
||||
}))
|
||||
|
||||
const swrFetcher = vi.fn()
|
||||
vi.mock("@/api/api", () => ({
|
||||
swrFetcher: (...args: unknown[]) => swrFetcher(...args),
|
||||
}))
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: (_key: string, _fetcher: unknown) => ({
|
||||
data: mockedRows,
|
||||
mutate: vi.fn(),
|
||||
error: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockProfile: { id: number; role: number } | undefined
|
||||
vi.mock("@/hooks/useAuth", () => ({
|
||||
useAuth: () => ({ profile: mockProfile }),
|
||||
}))
|
||||
vi.mock("@/hooks/useMainStore", () => ({
|
||||
useMainStore: (selector?: (s: { profile?: { id: number; role: number } }) => unknown) => {
|
||||
const store = { profile: mockProfile }
|
||||
return selector ? selector(store) : store
|
||||
},
|
||||
}))
|
||||
|
||||
import type { ModelServerTransfer } from "@/types"
|
||||
|
||||
let mockedRows: ModelServerTransfer[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
toastCalls.length = 0
|
||||
cancelServerTransfer.mockReset()
|
||||
retryServerTransfer.mockReset()
|
||||
mockedRows = []
|
||||
mockProfile = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ""
|
||||
})
|
||||
|
||||
function makeRow(overrides: Partial<ModelServerTransfer>): ModelServerTransfer {
|
||||
return {
|
||||
id: 1,
|
||||
server_id: 10,
|
||||
from_user_id: 100,
|
||||
to_user_id: 200,
|
||||
initiator_id: 100,
|
||||
status: 0,
|
||||
last_error: "",
|
||||
acked_at: "",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
} as ModelServerTransfer
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const { default: TransferPage } = await import("@/routes/transfer")
|
||||
await act(async () => {
|
||||
render(<TransferPage />)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Transfer.Title")).toBeTruthy()
|
||||
})
|
||||
}
|
||||
|
||||
test("non-admin member who is the FromUserID sees Cancel for pending rows but NOT Retry for terminal rows", async () => {
|
||||
mockProfile = { id: 100, role: 1 }
|
||||
mockedRows = [
|
||||
makeRow({ id: 1, status: 0, from_user_id: 100 }),
|
||||
makeRow({ id: 2, status: 2, from_user_id: 100 }),
|
||||
makeRow({ id: 3, status: 4, from_user_id: 100 }),
|
||||
]
|
||||
|
||||
await renderPage()
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Cancel" }).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
screen.queryAllByRole("button", { name: "Transfer.Retry" }),
|
||||
"Retry is admin-only on the backend; rendering it for members produces guaranteed permission_denied on click",
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("non-admin member who is only the ToUserID or InitiatorID sees neither Cancel nor Retry", async () => {
|
||||
mockProfile = { id: 200, role: 1 }
|
||||
mockedRows = [
|
||||
makeRow({ id: 1, status: 0, from_user_id: 100, to_user_id: 200 }),
|
||||
makeRow({ id: 2, status: 3, from_user_id: 100, to_user_id: 200 }),
|
||||
]
|
||||
|
||||
await renderPage()
|
||||
|
||||
expect(
|
||||
screen.queryAllByRole("button", { name: "Cancel" }),
|
||||
"backend cancelServerTransfer rejects non-admins that are not the FromUserID; UI must not pretend it works",
|
||||
).toHaveLength(0)
|
||||
expect(
|
||||
screen.queryAllByRole("button", { name: "Transfer.Retry" }),
|
||||
"backend retryServerTransfer is admin-only; non-admin must not see the button",
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("admin sees both Cancel for pending and Retry for terminal", async () => {
|
||||
mockProfile = { id: 1, role: 0 }
|
||||
mockedRows = [
|
||||
makeRow({ id: 1, status: 0 }),
|
||||
makeRow({ id: 2, status: 2 }),
|
||||
]
|
||||
|
||||
await renderPage()
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Cancel" }).length).toBe(1)
|
||||
expect(screen.queryAllByRole("button", { name: "Transfer.Retry" }).length).toBe(1)
|
||||
})
|
||||
Reference in New Issue
Block a user