11import { render as rtlRender , screen , within , waitFor } from "@testing-library/react"
22import { I18nProvider } from "@lingui/react"
33import { i18n } from "@lingui/core"
4- import { PortalProvider } from "@cloudoperators/juno-ui-components"
4+ import { PortalProvider , toast } from "@cloudoperators/juno-ui-components"
55import type { ReactNode } from "react"
66import { describe , it , expect , vi , beforeEach , afterEach } from "vitest"
77import userEvent from "@testing-library/user-event"
@@ -32,6 +32,22 @@ vi.mock("@/client/trpcClient", () => ({
3232 } ,
3333} ) )
3434
35+ // The component calls toast(...) directly for the "download started"
36+ // notification (a neutral/base call, not .success/.error/.warning), so the
37+ // mock needs the base export itself to be a spy — not just its sub-methods.
38+ vi . mock ( "@cloudoperators/juno-ui-components" , async ( importOriginal ) => {
39+ const actual = await importOriginal < typeof import ( "@cloudoperators/juno-ui-components" ) > ( )
40+ const toastFn = Object . assign ( vi . fn ( ) , {
41+ success : vi . fn ( ) ,
42+ error : vi . fn ( ) ,
43+ warning : vi . fn ( ) ,
44+ } )
45+ return {
46+ ...actual ,
47+ toast : toastFn ,
48+ }
49+ } )
50+
3551// Mock child components to isolate ObjectsTableView
3652const render = ( ui : React . ReactElement ) => {
3753 return rtlRender ( ui , {
@@ -43,6 +59,12 @@ const render = (ui: React.ReactElement) => {
4359 } )
4460}
4561
62+ // The toast helpers hand toast()/toast.warning() the raw <Trans> elements, not
63+ // rendered strings, so assertions match on the macro-generated `message` prop
64+ // (the source copy) rather than on the element's text.
65+ const transMessage = ( message : string | RegExp ) =>
66+ expect . objectContaining ( { props : expect . objectContaining ( { message : expect . stringMatching ( message ) } ) } )
67+
4668vi . mock ( "@tanstack/react-virtual" , ( ) => ( {
4769 useVirtualizer : ( { count } : { count : number } ) => ( {
4870 getVirtualItems : ( ) =>
@@ -268,6 +290,11 @@ describe("ObjectsTableView", () => {
268290
269291 describe ( "download and preview" , ( ) => {
270292 beforeEach ( ( ) => {
293+ // toast lives in the module mock factory, so vi.restoreAllMocks() in
294+ // afterEach leaves its call history intact — clear it explicitly or calls
295+ // leak between tests and "not.toHaveBeenCalled()" assertions see them.
296+ vi . mocked ( toast ) . mockClear ( )
297+ vi . mocked ( toast . warning ) . mockClear ( )
271298 downloadObjectMutate . mockReset ( )
272299 // Default: BFF returns text/plain (previewable)
273300 downloadObjectMutate . mockImplementation ( async ( ) => {
@@ -306,7 +333,8 @@ describe("ObjectsTableView", () => {
306333 containerName : "test-bucket" ,
307334 objectKey : "file1.txt" ,
308335 filename : "file1.txt" ,
309- } )
336+ } ) ,
337+ expect . objectContaining ( { signal : expect . any ( AbortSignal ) } )
310338 )
311339 } )
312340
@@ -475,6 +503,126 @@ describe("ObjectsTableView", () => {
475503 await waitFor ( ( ) => expect ( within ( row ) . queryByText ( "50%" ) ) . not . toBeInTheDocument ( ) )
476504 } )
477505
506+ it ( "fires the 'downloading' toast when a download starts" , async ( ) => {
507+ const user = userEvent . setup ( )
508+ vi . spyOn ( URL , "createObjectURL" ) . mockReturnValue ( "blob:mock" )
509+ vi . spyOn ( URL , "revokeObjectURL" ) . mockImplementation ( ( ) => { } )
510+
511+ render ( < ObjectsTableView { ...defaultProps } folders = { [ ] } objects = { [ mockObjects [ 0 ] ] } /> )
512+
513+ const row = screen . getByTestId ( "object-row-file1.txt" )
514+ await user . click ( within ( row ) . getByRole ( "button" , { name : / m o r e / i } ) )
515+ await user . click ( screen . getByTestId ( "download-action-file1.txt" ) )
516+
517+ await waitFor ( ( ) => expect ( toast ) . toHaveBeenCalledWith ( transMessage ( / ^ D o w n l o a d i n g / ) , expect . anything ( ) ) )
518+ } )
519+
520+ it ( "shows a cancel button while a transfer is in flight, and clicking it aborts the request" , async ( ) => {
521+ const user = userEvent . setup ( )
522+ vi . spyOn ( URL , "createObjectURL" ) . mockReturnValue ( "blob:mock" )
523+ vi . spyOn ( URL , "revokeObjectURL" ) . mockImplementation ( ( ) => { } )
524+
525+ let capturedSignal : AbortSignal | undefined
526+ let releaseGate ! : ( ) => void
527+ const gate = new Promise < void > ( ( resolve ) => {
528+ releaseGate = resolve
529+ } )
530+ downloadObjectMutate . mockImplementationOnce ( async ( _input , options : { signal ?: AbortSignal } ) => {
531+ capturedSignal = options ?. signal
532+ async function * gen ( ) {
533+ yield { chunk : btoa ( "a" ) , downloaded : 1 , total : 2 , contentType : "text/plain" , filename : "file1.txt" }
534+ await gate
535+ if ( capturedSignal ?. aborted ) {
536+ const abortError = new Error ( "Request canceled" )
537+ abortError . name = "AbortError"
538+ throw abortError
539+ }
540+ yield { chunk : btoa ( "b" ) , downloaded : 2 , total : 2 }
541+ }
542+ return gen ( )
543+ } )
544+
545+ render ( < ObjectsTableView { ...defaultProps } folders = { [ ] } objects = { [ mockObjects [ 0 ] ] } /> )
546+ const row = screen . getByTestId ( "object-row-file1.txt" )
547+ await user . click ( within ( row ) . getByRole ( "button" , { name : / m o r e / i } ) )
548+ await user . click ( screen . getByTestId ( "download-action-file1.txt" ) )
549+
550+ const cancelButton = await screen . findByTestId ( "cancel-transfer-file1.txt" )
551+ // The cancel control must be operable without a mouse: a real <button>
552+ // with an accessible name, not a clickable <div>/<span>.
553+ expect ( cancelButton . tagName ) . toBe ( "BUTTON" )
554+ expect ( cancelButton ) . toHaveAccessibleName ( "Cancel" )
555+
556+ await user . click ( cancelButton )
557+
558+ expect ( capturedSignal ?. aborted ) . toBe ( true )
559+
560+ releaseGate ( )
561+
562+ // A user-initiated cancellation is not an error — it is confirmed with a
563+ // toast, and onDownloadError must not fire for it.
564+ await waitFor ( ( ) => expect ( screen . queryByTestId ( "cancel-transfer-file1.txt" ) ) . not . toBeInTheDocument ( ) )
565+ await waitFor ( ( ) =>
566+ expect ( toast . warning ) . toHaveBeenCalledWith ( transMessage ( "Download Cancelled" ) , expect . anything ( ) )
567+ )
568+ expect ( defaultProps . onDownloadError ) . not . toHaveBeenCalled ( )
569+ } )
570+
571+ it ( "aborts in-flight transfers when the component unmounts" , async ( ) => {
572+ // The point of forwarding the AbortSignal to the tRPC call: navigating
573+ // away mid-download must actually stop the request, not just ignore its
574+ // result while it keeps running (and competing for bandwidth/CPU) in
575+ // the background.
576+ const user = userEvent . setup ( )
577+ vi . spyOn ( URL , "createObjectURL" ) . mockReturnValue ( "blob:mock" )
578+ vi . spyOn ( URL , "revokeObjectURL" ) . mockImplementation ( ( ) => { } )
579+
580+ let capturedSignal : AbortSignal | undefined
581+ let releaseGate ! : ( ) => void
582+ const gate = new Promise < void > ( ( resolve ) => {
583+ releaseGate = resolve
584+ } )
585+ // The stream must reject once the signal is aborted, exactly as it does in
586+ // the cancel test — otherwise it completes normally, handleTransferError()
587+ // is never reached, and the assertion below would pass for the wrong
588+ // reason (no error path taken) rather than because the isMounted guard
589+ // suppressed the toast.
590+ downloadObjectMutate . mockImplementationOnce ( async ( _input , options : { signal ?: AbortSignal } ) => {
591+ capturedSignal = options ?. signal
592+ async function * gen ( ) {
593+ yield { chunk : btoa ( "a" ) , downloaded : 1 , total : 2 , contentType : "text/plain" , filename : "file1.txt" }
594+ await gate
595+ if ( capturedSignal ?. aborted ) {
596+ const abortError = new Error ( "Request canceled" )
597+ abortError . name = "AbortError"
598+ throw abortError
599+ }
600+ yield { chunk : btoa ( "b" ) , downloaded : 2 , total : 2 }
601+ }
602+ return gen ( )
603+ } )
604+
605+ const { unmount } = render ( < ObjectsTableView { ...defaultProps } folders = { [ ] } objects = { [ mockObjects [ 0 ] ] } /> )
606+ const row = screen . getByTestId ( "object-row-file1.txt" )
607+ await user . click ( within ( row ) . getByRole ( "button" , { name : / m o r e / i } ) )
608+ await user . click ( screen . getByTestId ( "download-action-file1.txt" ) )
609+
610+ unmount ( )
611+
612+ expect ( capturedSignal ?. aborted ) . toBe ( true )
613+
614+ releaseGate ( )
615+
616+ // Let the aborted stream reject and handleTransferError() run before
617+ // asserting on what it did (and didn't) do.
618+ await waitFor ( ( ) => expect ( defaultProps . onDownloadError ) . not . toHaveBeenCalled ( ) )
619+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) )
620+
621+ // An unmount-triggered abort is not a user-initiated cancellation — the
622+ // component (and the page it lived on) is gone, so no toast is shown.
623+ expect ( toast . warning ) . not . toHaveBeenCalled ( )
624+ } )
625+
478626 it ( "tracks two concurrent transfers independently — finishing one must not clear the other's state" , async ( ) => {
479627 // Regression test for a bug where a single shared piece of state tracked
480628 // the "active" row/downloadId. Starting a second transfer overwrote the
0 commit comments