@@ -789,6 +789,211 @@ describe("useUploadKit", () => {
789789 } )
790790 } )
791791
792+ describe ( "appendExistingFiles" , ( ) => {
793+ const defaultGetRemoteFileFn = async ( storageKey : string ) => ( {
794+ size : 2048 ,
795+ mimeType : storageKey . endsWith ( ".png" ) ? "image/png" : "image/jpeg" ,
796+ remoteUrl : `https://storage.example.com/${ storageKey } ` ,
797+ } )
798+
799+ it ( "should append remote files without replacing existing local files" , async ( ) => {
800+ const storage = createMockStoragePlugin ( { getRemoteFileFn : defaultGetRemoteFileFn } )
801+ const uploader = useUploadKit ( { storage } )
802+
803+ await uploader . addFile ( createMockFile ( "local.jpg" ) )
804+ const added = await uploader . appendExistingFiles ( [ { storageKey : "remote-1.png" } , { storageKey : "remote-2.png" } ] )
805+
806+ expect ( added ) . toHaveLength ( 2 )
807+ expect ( uploader . files . value ) . toHaveLength ( 3 )
808+
809+ // Original local file preserved at index 0
810+ expect ( uploader . files . value [ 0 ] ! . source ) . toBe ( "local" )
811+ expect ( uploader . files . value [ 0 ] ! . name ) . toBe ( "local.jpg" )
812+
813+ // Appended files are complete remote files
814+ const appended = uploader . files . value . slice ( 1 )
815+ for ( const file of appended ) {
816+ expect ( file . source ) . toBe ( "storage" )
817+ expect ( file . status ) . toBe ( "complete" )
818+ expect ( file . progress . percentage ) . toBe ( 100 )
819+ expect ( file . data ) . toBeNull ( )
820+ expect ( file . remoteUrl ) . toMatch ( / ^ h t t p s : \/ \/ s t o r a g e \. e x a m p l e \. c o m \/ / )
821+ }
822+ } )
823+
824+ it ( "should set correct metadata from storage plugin on appended files" , async ( ) => {
825+ const storage = createMockStoragePlugin ( {
826+ getRemoteFileFn : async ( storageKey ) => ( {
827+ size : 4096 ,
828+ mimeType : "image/webp" ,
829+ remoteUrl : `https://cdn.example.com/${ storageKey } ` ,
830+ preview : `https://cdn.example.com/thumbs/${ storageKey } ` ,
831+ uploadResult : { storageKey, bucket : "media" } ,
832+ } ) ,
833+ } )
834+ const uploader = useUploadKit ( { storage } )
835+
836+ const added = await uploader . appendExistingFiles ( [ { storageKey : "photos/pic.webp" } ] )
837+
838+ expect ( added ) . toHaveLength ( 1 )
839+ const file = added [ 0 ] !
840+ expect ( file . size ) . toBe ( 4096 )
841+ expect ( file . mimeType ) . toBe ( "image/webp" )
842+ expect ( file . name ) . toBe ( "pic.webp" )
843+ expect ( file . storageKey ) . toBe ( "photos/pic.webp" )
844+ expect ( file . remoteUrl ) . toBe ( "https://cdn.example.com/photos/pic.webp" )
845+ expect ( file . preview ) . toBe ( "https://cdn.example.com/thumbs/photos/pic.webp" )
846+ expect ( file . uploadResult ) . toEqual ( { storageKey : "photos/pic.webp" , bucket : "media" } )
847+ } )
848+
849+ it ( "should skip duplicates already present by storageKey" , async ( ) => {
850+ const getRemoteFileFn = vi . fn ( defaultGetRemoteFileFn )
851+ const storage = createMockStoragePlugin ( { getRemoteFileFn } )
852+ const uploader = useUploadKit ( { storage } )
853+
854+ await uploader . initializeExistingFiles ( [ { storageKey : "existing.png" } ] )
855+ getRemoteFileFn . mockClear ( )
856+
857+ const added = await uploader . appendExistingFiles ( [ { storageKey : "existing.png" } , { storageKey : "new.png" } ] )
858+
859+ expect ( added ) . toHaveLength ( 1 )
860+ expect ( added [ 0 ] ! . storageKey ) . toBe ( "new.png" )
861+ expect ( uploader . files . value ) . toHaveLength ( 2 )
862+ // Should only call getRemoteFile for the non-duplicate
863+ expect ( getRemoteFileFn ) . toHaveBeenCalledTimes ( 1 )
864+ expect ( getRemoteFileFn ) . toHaveBeenCalledWith ( "new.png" )
865+ } )
866+
867+ it ( "should deduplicate against uploaded local files that have storageKey" , async ( ) => {
868+ const storage = createMockStoragePlugin ( {
869+ getRemoteFileFn : defaultGetRemoteFileFn ,
870+ uploadFn : async ( ) => ( {
871+ url : "https://storage.example.com/uploads/local.jpg" ,
872+ storageKey : "uploads/local.jpg" ,
873+ } ) ,
874+ } )
875+ const uploader = useUploadKit ( { storage } )
876+
877+ // Add and upload a local file so it gets a storageKey
878+ await uploader . addFile ( createMockFile ( "local.jpg" ) )
879+ await uploader . upload ( )
880+ expect ( uploader . files . value [ 0 ] ! . storageKey ) . toBe ( "uploads/local.jpg" )
881+
882+ // Appending the same storageKey should be deduplicated
883+ const added = await uploader . appendExistingFiles ( [ { storageKey : "uploads/local.jpg" } ] )
884+
885+ expect ( added ) . toHaveLength ( 0 )
886+ expect ( uploader . files . value ) . toHaveLength ( 1 )
887+ } )
888+
889+ it ( "should respect maxFiles limit and preserve insertion order" , async ( ) => {
890+ const storage = createMockStoragePlugin ( { getRemoteFileFn : defaultGetRemoteFileFn } )
891+ const uploader = useUploadKit ( { storage, maxFiles : 3 } )
892+
893+ await uploader . addFile ( createMockFile ( "file1.jpg" ) )
894+ await uploader . addFile ( createMockFile ( "file2.jpg" ) )
895+
896+ // Only 1 slot available — should take the first from the input
897+ const added = await uploader . appendExistingFiles ( [
898+ { storageKey : "remote-1.jpg" } ,
899+ { storageKey : "remote-2.jpg" } ,
900+ { storageKey : "remote-3.jpg" } ,
901+ ] )
902+
903+ expect ( added ) . toHaveLength ( 1 )
904+ expect ( added [ 0 ] ! . storageKey ) . toBe ( "remote-1.jpg" )
905+ expect ( uploader . files . value ) . toHaveLength ( 3 )
906+ } )
907+
908+ it ( "should return empty array when maxFiles is already reached" , async ( ) => {
909+ const getRemoteFileFn = vi . fn ( defaultGetRemoteFileFn )
910+ const storage = createMockStoragePlugin ( { getRemoteFileFn } )
911+ const uploader = useUploadKit ( { storage, maxFiles : 1 } )
912+
913+ await uploader . addFile ( createMockFile ( "file1.jpg" ) )
914+ getRemoteFileFn . mockClear ( )
915+
916+ const added = await uploader . appendExistingFiles ( [ { storageKey : "remote-1.jpg" } ] )
917+
918+ expect ( added ) . toHaveLength ( 0 )
919+ // Should not make any network calls when limit is reached
920+ expect ( getRemoteFileFn ) . not . toHaveBeenCalled ( )
921+ } )
922+
923+ it ( "should emit file:added for each appended file" , async ( ) => {
924+ const storage = createMockStoragePlugin ( { getRemoteFileFn : defaultGetRemoteFileFn } )
925+ const uploader = useUploadKit ( { storage } )
926+ const handler = vi . fn ( )
927+
928+ uploader . on ( "file:added" , handler )
929+ await uploader . appendExistingFiles ( [ { storageKey : "remote-1.jpg" } , { storageKey : "remote-2.jpg" } ] )
930+
931+ expect ( handler ) . toHaveBeenCalledTimes ( 2 )
932+ expect ( handler ) . toHaveBeenCalledWith ( expect . objectContaining ( { storageKey : "remote-1.jpg" } ) )
933+ expect ( handler ) . toHaveBeenCalledWith ( expect . objectContaining ( { storageKey : "remote-2.jpg" } ) )
934+ } )
935+
936+ it ( "should not emit file:added when all files are duplicates" , async ( ) => {
937+ const storage = createMockStoragePlugin ( { getRemoteFileFn : defaultGetRemoteFileFn } )
938+ const uploader = useUploadKit ( { storage } )
939+
940+ await uploader . initializeExistingFiles ( [ { storageKey : "file-a.jpg" } ] )
941+
942+ const handler = vi . fn ( )
943+ uploader . on ( "file:added" , handler )
944+ const added = await uploader . appendExistingFiles ( [ { storageKey : "file-a.jpg" } ] )
945+
946+ expect ( added ) . toHaveLength ( 0 )
947+ expect ( handler ) . not . toHaveBeenCalled ( )
948+ } )
949+
950+ it ( "should handle multiple sequential appends correctly" , async ( ) => {
951+ const storage = createMockStoragePlugin ( { getRemoteFileFn : defaultGetRemoteFileFn } )
952+ const uploader = useUploadKit ( { storage } )
953+
954+ await uploader . appendExistingFiles ( [ { storageKey : "batch-1.jpg" } ] )
955+ await uploader . appendExistingFiles ( [ { storageKey : "batch-2.jpg" } ] )
956+ await uploader . appendExistingFiles ( [ { storageKey : "batch-1.jpg" } , { storageKey : "batch-3.jpg" } ] )
957+
958+ expect ( uploader . files . value ) . toHaveLength ( 3 )
959+ expect ( uploader . files . value . map ( ( f ) => f . storageKey ) ) . toEqual ( [ "batch-1.jpg" , "batch-2.jpg" , "batch-3.jpg" ] )
960+ } )
961+
962+ it ( "should throw if no storage plugin with getRemoteFile is configured" , async ( ) => {
963+ const uploader = useUploadKit ( )
964+
965+ await expect ( uploader . appendExistingFiles ( [ { storageKey : "remote.jpg" } ] ) ) . rejects . toThrow (
966+ "Storage plugin with getRemoteFile hook is required" ,
967+ )
968+ } )
969+
970+ it ( "should skip entries with empty storageKey without calling storage" , async ( ) => {
971+ const getRemoteFileFn = vi . fn ( defaultGetRemoteFileFn )
972+ const storage = createMockStoragePlugin ( { getRemoteFileFn } )
973+ const uploader = useUploadKit ( { storage } )
974+
975+ const added = await uploader . appendExistingFiles ( [ { storageKey : "" } , { storageKey : "valid.jpg" } ] )
976+
977+ expect ( added ) . toHaveLength ( 1 )
978+ expect ( added [ 0 ] ! . storageKey ) . toBe ( "valid.jpg" )
979+ expect ( getRemoteFileFn ) . toHaveBeenCalledTimes ( 1 )
980+ } )
981+
982+ it ( "should allow removal of appended files via removeFile" , async ( ) => {
983+ const removeHook = vi . fn ( )
984+ const storage = createMockStoragePlugin ( { getRemoteFileFn : defaultGetRemoteFileFn , removeFn : removeHook } )
985+ const uploader = useUploadKit ( { storage } )
986+
987+ const added = await uploader . appendExistingFiles ( [ { storageKey : "library/photo.jpg" } ] )
988+ expect ( uploader . files . value ) . toHaveLength ( 1 )
989+
990+ await uploader . removeFile ( added [ 0 ] ! . id )
991+
992+ expect ( uploader . files . value ) . toHaveLength ( 0 )
993+ expect ( removeHook ) . toHaveBeenCalledWith ( expect . objectContaining ( { storageKey : "library/photo.jpg" } ) )
994+ } )
995+ } )
996+
792997 describe ( "event system" , ( ) => {
793998 it ( "should allow registering and receiving events" , async ( ) => {
794999 const uploader = useUploadKit ( )
0 commit comments