From 592b10acc244065f179ab6259f7963fa96d5fc74 Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 13:53:53 +0100 Subject: [PATCH] feat: download - download status aan logs toegevoegd --- .../history_repository.cpython-313.pyc | Bin 7766 -> 7813 bytes webui/backend/app/db/history_repository.py | 4 +- .../file_ops_service.cpython-313.pyc | Bin 37820 -> 42649 bytes .../backend/app/services/file_ops_service.py | 165 ++++++++++++++++-- webui/backend/data/tasks.db | Bin 200704 -> 200704 bytes .../test_api_history_golden.cpython-313.pyc | Bin 13751 -> 19998 bytes .../tests/golden/test_api_history_golden.py | 72 ++++++++ .../test_history_repository.cpython-313.pyc | Bin 2340 -> 2314 bytes 8 files changed, 228 insertions(+), 13 deletions(-) diff --git a/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc index 007e0fa0b6f5d2cdb9d47f43dad53c32750a631d..fc5a4d6275bcd1ff1c7424fdf0a658e251c59625 100644 GIT binary patch delta 1162 zcmah|&rcIU6z*)7Z7I|cBi%x`wIB$qf;C{!P#Qzx#V9eD7!yIP-6`E<*)6kM8;FsU z@!*NfU*L@kF=qAP#dz`JMZ9pB|G+Dfkmv#D&6HLX6Zi1-n>X)$@5j9E7hlXJe*5;ytfaxlizM?{J^>hE?XN+tCnpCTTEMT;0FKgu6S#82M0h2=0+cAU!CBpW3SeN z73p7!Z68l+EN*#CH#E@>(k?XdlZGAcD;p{@=-rq;GR)JEa{wa%qX6dt#sJ0vO!_KT zLb>ouY#8-)9LDKU{1{!NN8LM1m%(omU;`x!Cs@ksY*RZwn zwCpr6DRQG9=a6rGBp3cl)D)vF$>LQURBC0%Av4_1)QCa1k|_fsnM%B-Pv$@b<|j3{ z#M|V&Qn>^kifxf=oXBea-@O^_bi<;nIN%0oiFIK&ARDsasLaI^-S5-8ZVbY`RxcB9 zDL}G5fOZQl36ce?=(sHamQ1+Y{|TW1>gs7UNMGt>I%Jfd3TOE|$$?>-e%8O{2RJnm zQ!Tp^m`FB{z)L#K(0A!MG!!1BdyzH-VLAFHouxyAx3}QPL!e$$k>xWc%RJhPq>r>E1zG4IB7Q{5->3ikp8x;= delta 1108 zcmZuwO-~b16z!W%hgw?G)U-471ItIjK|UmCqJYF`Vq#*@urpvt(;4iT(&C#z0x@C5 z+K{()=fahnF}l+D6L8~V;zD<_aH9*OF7%!^rA6Z`PR~2{zH{Hb?{)9;`}x$jR4Ngn zPt{&HZ}wB~)F>_X4-3kICWhFgzs#Ppm^oG5*zjY~ZIu~fw?|L+QxZQ7C<6w>bXpg$ zd*)c)|IstZdIz8m0*1xNiQjBgj3l0|oP{t3m;#&wOcR)KZh%(+7XZih(=s@@<}@9* zZdKCdr|c<_G+tG*v@}_uT29R~SvUoT^e`LK3i2}hmr-FQf1oeP(j`*EIBu>Tzn#Ci zivYQx7gGt3idM{AI6m2yyiv(8$e#(<RVr*e(4(*RIz(B?l^wWU@mGE-JqcE$YzaAi9%iIIg#h zsokaEB1u$^eq@w4u}3+^RXN$6jQgPBc=pWXQfup|X=&FBr?zsmaa?>Z%*_{IJquU> z+yLAH=l~TUuQcg;RnMud+g`n8$yd&&#A>mSy^oacjb*UR_>YToOp|Ix9F%nJ4&({( zr8Hs6|D`F@0Dtcf;`8e*YqMc5@Evijzfzh#h~$F8(m}-duB|A{VqYWjN0$5p`ikC^ diff --git a/webui/backend/app/db/history_repository.py b/webui/backend/app/db/history_repository.py index 2073f08..38c9363 100644 --- a/webui/backend/app/db/history_repository.py +++ b/webui/backend/app/db/history_repository.py @@ -6,8 +6,8 @@ from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path -VALID_HISTORY_STATUSES = {"queued", "completed", "failed"} -VALID_HISTORY_OPERATIONS = {"mkdir", "rename", "delete", "copy", "move", "upload"} +VALID_HISTORY_STATUSES = {"queued", "completed", "failed", "requested", "ready", "preflight_failed"} +VALID_HISTORY_OPERATIONS = {"mkdir", "rename", "delete", "copy", "move", "upload", "download"} class HistoryRepository: diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index 9aa3599999595712afe2cc3e56e7b0b550dc30bb..3d2523da7f0046eb0693e61512c4a910a6fb1871 100644 GIT binary patch delta 8608 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 diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index 0b399d8..bccfe9c 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -387,17 +387,74 @@ class FileOpsService: ) def prepare_download(self, paths: list[str]) -> dict: + history_entry_id: str | None = None + history_mode = self._download_mode_from_request_paths(paths) + history_path = self._summarize_download_targets(paths) + history_download_name: str | None = None if not paths: - raise AppError( + error = AppError( code="invalid_request", message="At least one path is required", status_code=400, ) + self._record_download_failure( + mode=history_mode, + path_summary=history_path, + download_name=None, + error=error, + history_entry_id=None, + ) + raise error - resolved_targets = [self._path_guard.resolve_existing_path(path) for path in paths] - if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_file(): - return self._prepare_single_file_download(resolved_targets[0]) - return self._prepare_zip_download(resolved_targets) + try: + resolved_targets = [self._path_guard.resolve_existing_path(path) for path in paths] + history_mode = self._download_mode_from_resolved_targets(resolved_targets) + history_path = self._summarize_download_targets([target.relative for target in resolved_targets]) + history_download_name = self._download_name_for_targets(resolved_targets) + history_entry_id = self._record_download_status( + status="requested", + mode=history_mode, + path_summary=history_path, + download_name=history_download_name, + ) + + if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_file(): + prepared = self._prepare_single_file_download(resolved_targets[0]) + else: + prepared = self._prepare_zip_download(resolved_targets, history_download_name) + + self._record_download_status( + status="ready", + mode=history_mode, + path_summary=history_path, + download_name=history_download_name, + history_entry_id=history_entry_id, + ) + return prepared + except AppError as error: + self._record_download_failure( + mode=history_mode, + path_summary=history_path, + download_name=history_download_name, + error=error, + history_entry_id=history_entry_id, + ) + raise + except OSError as exc: + error = AppError( + code="io_error", + message="Filesystem operation failed", + status_code=500, + details={"reason": str(exc)}, + ) + self._record_download_failure( + mode=history_mode, + path_summary=history_path, + download_name=history_download_name, + error=error, + history_entry_id=history_entry_id, + ) + raise error def save(self, path: str, content: str, expected_modified: str) -> SaveResponse: resolved_target = self._path_guard.resolve_existing_path(path) @@ -699,7 +756,7 @@ class FileOpsService: "content_type": self._content_type_for(resolved_target.absolute) or "application/octet-stream", } - def _prepare_zip_download(self, resolved_targets: list) -> dict: + def _prepare_zip_download(self, resolved_targets: list, download_name: str) -> dict: archive_names: set[str] = set() for resolved_target in resolved_targets: archive_name = resolved_target.absolute.name @@ -712,11 +769,6 @@ class FileOpsService: archive_names.add(archive_name) self._run_zip_download_preflight(resolved_targets) - if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_dir(): - download_name = f"{resolved_targets[0].absolute.name}.zip" - else: - download_name = f"kodidownload-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.zip" - buffer = BytesIO() with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: for resolved_target in resolved_targets: @@ -734,6 +786,97 @@ class FileOpsService: "content_type": "application/zip", } + def _download_name_for_targets(self, resolved_targets: list) -> str: + if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_file(): + return resolved_targets[0].absolute.name + if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_dir(): + return f"{resolved_targets[0].absolute.name}.zip" + return f"kodidownload-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.zip" + + @staticmethod + def _download_mode_from_request_paths(paths: list[str]) -> str: + return "multi_zip" if len(paths) > 1 else "single_file" + + @staticmethod + def _download_mode_from_resolved_targets(resolved_targets: list) -> str: + if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_file(): + return "single_file" + if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_dir(): + return "single_directory_zip" + return "multi_zip" + + @staticmethod + def _summarize_download_targets(paths: list[str]) -> str: + if not paths: + return "-" + if len(paths) == 1: + return paths[0] + if len(paths) == 2: + return f"{paths[0]}, {paths[1]}" + return f"{paths[0]}, {paths[1]}, +{len(paths) - 2} more" + + def _record_download_status( + self, + *, + status: str, + mode: str, + path_summary: str, + download_name: str | None, + history_entry_id: str | None = None, + ) -> str | None: + if not self._history_repository: + return history_entry_id + if history_entry_id: + self._history_repository.update_entry( + entry_id=history_entry_id, + status=status, + error_code=None, + error_message=None, + finished_at=self._now_iso(), + ) + return history_entry_id + created = self._history_repository.create_entry( + operation="download", + status=status, + source=mode, + destination=download_name, + path=path_summary, + finished_at=self._now_iso() if status != "requested" else None, + ) + return created["id"] + + def _record_download_failure( + self, + *, + mode: str, + path_summary: str, + download_name: str | None, + error: AppError, + history_entry_id: str | None, + ) -> None: + if not self._history_repository: + return + failure_status = "preflight_failed" if error.code == "download_preflight_failed" else "failed" + if history_entry_id: + self._history_repository.update_entry( + entry_id=history_entry_id, + status=failure_status, + error_code=error.code, + error_message=error.message, + finished_at=self._now_iso(), + ) + return + self._history_repository.create_entry( + operation="download", + status=failure_status, + source=mode, + destination=download_name, + path=path_summary, + error_code=error.code, + error_message=error.message, + finished_at=self._now_iso(), + ) + def _run_zip_download_preflight(self, resolved_targets: list) -> None: started_at = self._monotonic() state = ZipDownloadPreflightState() diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index b66e45806ce957cba027654564748f819d930f8e..f6e479103ca4cef73c769b31ee00ce245c8770a3 100644 GIT binary patch delta 1783 zcma)5c}x^n9G>5s*=u%Y2NbD94QpMMWgT{Bc86t&2YA%jfD#UCQG>fXi=x0Hh(|p* ztZh<*#Vqk9S}UeT)6}c+*wn;I8WUq}qBKplB~=kQL^+I28f|U+W(g*>rgi_=y!ZQl z$M^f*H}$PteJj^I0b2G>oChsmt~z>(JwlP6UHD!&awh)tK>?qPpeZcXL4wJ4x(MNm z7TSg&zOn6m8-TTr#8*;1lyFlxDeMxK3zA^4{9-w4*=-3}GAsh$!(ZfE_;q|XKb{-p zE^`OC_1r>klKEHjHS=NfCUdUY#y(@)*<wvbI_!F0=X%JhM0g~@GVjh)7*agR|o zW*XxRJ%)>hh+(ZE%aEua&|lW?*H`J^nya6vd!oClJEYsF%hA2bJY{Y$N11I*K4WJn z`bYXW-AFH^(`W|$j83Bu(Mse-EObH?nji>15U3vf&0VlDDRdFw$qLxOiGS;XG;DSt z8=^kKZ{H)AlKYT~=^mJdr5Z@VS9;0yyG{a#TI_KzImz8%$DLgS7;Z*3{Ja|sIJplB z@UCuf;F>;|imM1ZGHBK!u;I2o*nz+3CKalbi8`j{}dVYc%9>P|@bM0z5pKliEMhHH69X8P6?V6D5?vi{!*sAR}VSq5*tSOh@Nv_r|B45P-+2@^Z z5^E1=txrey4S*H!GeQwhTu=1AJqYjO)J~F?J_I7R_mZlTA^5EBYib4cNHgCF;eDZ4 za1ry}j+xJI@mhGk3)b=%_-1I~SM#&@v0Ojb#_i+Exw%}DdDwi#e8{}foMWECK4q`7 zM`H$bu*h_adPFR^%e34inT*EU#&gEqM!(T(X|) zYNC3BF4?aJe47puSc-5ei?hOSz zkPv&=2A(GGH->!Csf5_H=%vRn-f|W(S?pfO0J?!bK%a{mv(O}nI>QZXbZ(F&4Xa=Pepi;|~MB8ZgfQi@^RzY$M*ri;`hM6b_7%1r%om`TCRsIeZ& z5c&L4>WJ4)aI`JDrvYu;5vMn$P?qC-6?f1)%yyVk&PZ_yrn;TZ#zd}Yl9-&kGk4BH zMR^^0)bOUt@~~g6st%Qwm#tp2p`_GbRvuC-YRfm2mAqG0wJxldjV6iOJ}ymRNmgXH zltysp(r|fgMX1J^5!qTW8GkI$i_i>fWPO1H-{I-0IPM9GubW|Y$kvK*b!fCx$;<6>E%d97hCrBRRKmEJZ2GsFU*l%SxkDt3!Tu%m1$}xfE^f zNc@v*vL}AL7^ZVea^^3{FDc5)S&}^;PaZ-^d83d063dtY8tjb;Tv1XoMK#20EMLc! zxIKa#i*w2X)&A-&&cdZ71#7~aYMcw#gljg`SW{$|#i9o} HI!ONw>vs;Q delta 1080 zcmXw2eN0tl7(dVZzUTW37;TuiAkJl8G!&i0rWl0PW+5$NT9=wr7k@Y%Y(g#rxh>qX zfP3fSVJ3ntsZ~g&a5u^UjV;aofLt@i3@)FS3tc|iYB8I==Thg7XV3dPzu)h9&hxyd zzQd^RFajH)Iha}m&0inx?AERd{PEg%?W@-l(^_pv!##eIrEu`E&9n?KeHS>e?VW+% z0f4l#bVw){h6Vecea&vLeR8^;1CFg&<5sV*W;I&Jtld_UIdArxE%2v!&^$p?;Q*XA z_n8|>8hk9(8mna0xXl{KDWijR8*ds143GYRUexdEpX+{dOFtwG>)A}!nKs69v~Q$R z=~L}J?JyhFa^>frrq61oIwexIk6cy*YK6L2{6bAuqRODsN-LFpkJSzS|?~6~! zE0Py}l5ffF;zQ;m`{iosFKGn7p8yB5-y%sw_zv@8kcIbSunk{W1TUs9^5dBW9!icA zhX|b*bCD|?VP0BBu&L68?_g~dvhl?k$Xtj0PrC3WT#Iscl`Bb|<(|!TB!dV&SU%77 z(ulAjSop}gpg-yg{>Fp|r?t+32OGoOaZ@#TAck44&Ba+B+GjX=W0GgLM~Nc|k$4xi zr-0{^@!afk@rii89B^I7F%;&4*IZS>91q0{+~lK6E^nCcH8bdc9BsGUt|k62#jmCyvt=4Oad;Zu#v0yJOBTt%l^NKM)wAGwpdWN>94QriRt@g6EMI-8{+O3{b z%hesKqKqp&$_L63B~P*BX}M1h$Q5$FoGdL!gAz(7rRScJQpG>SAH^%;X|Y7iWDnTS z>@!x!%Gef0=&$q|ZJ_0JCsoOW5F@>$ksKqtNg_m`A3lUaNb%D6(r#MRA&`g#D|~eJ zJ)tz{FZu6QnbeUWDbDJ;;I3~hzMcGU!3ia-!m`QSe>dp&+#sxgH9;PPtOBwL#Ph6* z#X?zyq?mv&=s$>gg(M*~UPL^5*sJh{5Yp<&4hROet{3Vus1s`PlbSk>8f}90rCDjT PUC&8qHbQL%Jxc!tewayY diff --git a/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc index 428e1598978efd427a4478a6af413fdae95235be..2edd15101bae1a910133f71517f88c3381a26b75 100644 GIT binary patch delta 4080 zcmbVPZERat8NS!?$FUtdAO48#B#v9BX`IGMlQn4(?b;4H+9}Jr+@&3?!kGFdxrq~p zbKSKy6cOCbt@H4k)7KiTkm`BC@d%%8A zI44k15~xWMX`SSiOd(;~OwEAmII>7S$;^>e@=Gm}74fu4ZBjrAO19Dyf%$~bvIDYn zYcR2LPan8 zV6XRX*xIy@-D~rk_M2EF9${bY^0H65JK3?0gLSqc6AO2AI4lwwGU0!Oz&?ok*y%10 zi^Y5_7j0#&JznHMDJuM9;$zg%HQWKBO%-yrA%y~E`ZsNS06OhXrxjvmM zZ7W2CNg?JR-EdHOK0~v8*-P0C_mrGYXCkT0#kq7QnW8_3J?T9R(-1a#`;+rZ|Adm3 z)qZ2n(fMR{>WG@n(Bz~%&|g`MzKwZr33-U$MTlRQ-hxmhMl!vEj}|H?Qgz znoU&gmM{*LxJVTkOz=}BrWD-&assH?;z{^@LsL(e^bT(A6$Rm{@X+qM9(pr$BdytY zuaQ2D^sV$C{b=tB!m&r5mRm<|9$ECQbf0}v0z(o-Nu9+LVs63|Fs zp=Gh@c4+aW7K|@lERcg+4qVwH<9RK(ZzcX*fqbquWul+!Q9IBo1 zk`_F$oLVKHWy@`?rpAh{Pj`{Jh%nCn9`tofV%!NDHDMe>vDN%Zb&^@Ke8$ACb_ALw zs{~%qXUvJ?!kC>6wEI}I&0~@X=r&@88FuzwY>$&mf6i2z<7qNUPA=to9$nz5x8vdF zaf5bqxN%A8komLz(hC?2nseb=YU9v3hK4|3bA zclr^0O)ho%8$?{_-ja$^N}kH+xx{kHJeT~2t1NlGE{c5vJab;{gDEkaF80H?)Rv0J zfyV`ZxLWWjrLt+){7$6*GD<&RK6E90C)ppuk<|Y{sCZyPv$w60s79g%vd1t6k_#6# zd)FEn)X3oSGw-*oAUahthZEg#`hn-=DtEL$(H-m$z5d}71@e5YqR|aYi7jlx&G5o1 zIm)H}LPhGIbi3F;f*vASbb+&D(U75ir%p;%7-GpTACYXRc0=n#vTv<*_Hn!cE_#ny zA|O)~I~VmPvAejQA5*ofTGcLAQgtKfT~E+UfRFtxzB^x{{CuSr<0!lW1V(dE0g|a% zSy%s9Mg2wV{8T2JQFAtBc2XZb%?m;9_@_IfvS!zOep89i7kxUn*k>=Kvr58nNUpQ& zkjg&2kuq7@0*#aF`0`9XlTwV^J$^3r^+Q)M4qrKke(H>os_MaARa-(}nW8hPsxr-v z$&>P>E!=gGyGuXita3?C;aLq|wWpp?Q%1P4w#j@#;sf+lV4J$Kq`rHWmz5KSwWc(C zc#Z7Q$ese(TTLzoHG2<~qhXBXt~sV`b$jBcvJ=)IJ;YL4DD`Ky=~zD@v{yuNafGoeks zY8}lH&}Lfq&83Q~=(zH4L>F{Oc1uKAH~LbDo>=9$*F8&BB`YF za*9_Is$mwkOY}Yl-X5Vqc#l*J8sblEPKRu4pK|SAQs&D|U!*)If?LR`$NW)rsF!E) z{0X=n9_zPY!I+u0!AH@WP0~p@OTUdLS%RRNAwUaO>cKwGO|#A6E>PKUP5DG@*M6_5 z_(gQgzNbvf{~7lm(f!ioyA`?_TA0?Fy02Lu689PjY9zQ2U9znp+Hd${`y!Ro?6Ea6 zq>-WJ2`C~}b}5yoa=R8-3&x>WW20d!HgjeyS6G$I#|z}S|F=99pgeu6+aDe+ke6yb zhP~R#@c1e@gB}x4vX1z!9lnEzF7TL#U5>h}1A6 z{S}7IZwmC+$dz6I09Evi4ga&K0=*UbCZM97%3r$x-8jhc5e{RYUbLvm*MWey2chUl z5rQH delta 278 zcmbO?hjDv0-)CN4E(Rco*}E~*NNFPvr%)$J@Oi~P4EJZ9p7BiT|8Y~dZ0%oxV3kC}X zvw_%Ij799h!oecJ>`)#DP(24&6(^9z31)EtSzKTiH;}~*X7K=7JYW`YupoahFPO;} z%vZ#p&Z;T8S%FPmdvcuT3Pyp+>|Q>TbG(!o?I!no*|2_>;$gMl{MM_PiR%{7)r>$~ fJZo~5Z?||b6QlhMi|f)l7o~N+GJtqR%0O8Frffmr diff --git a/webui/backend/tests/golden/test_api_history_golden.py b/webui/backend/tests/golden/test_api_history_golden.py index 6adfc8e..32f0d73 100644 --- a/webui/backend/tests/golden/test_api_history_golden.py +++ b/webui/backend/tests/golden/test_api_history_golden.py @@ -178,3 +178,75 @@ class HistoryApiGoldenTest(unittest.TestCase): self.assertEqual(history[0]['operation'], 'move') self.assertEqual(history[0]['status'], 'failed') self.assertEqual(history[0]['error_code'], 'io_error') + + def test_single_file_download_writes_ready_history_item(self) -> None: + (self.root1 / 'report.txt').write_text('hello download', encoding='utf-8') + + response = self._request('GET', '/api/files/download?path=storage1/report.txt') + + self.assertEqual(response.status_code, 200) + history = self._request('GET', '/api/history').json()['items'] + self.assertEqual(history[0]['operation'], 'download') + self.assertEqual(history[0]['status'], 'ready') + self.assertEqual(history[0]['source'], 'single_file') + self.assertEqual(history[0]['path'], 'storage1/report.txt') + self.assertEqual(history[0]['destination'], 'report.txt') + self.assertEqual(history[0]['error_code'], None) + self.assertEqual(history[0]['error_message'], None) + + def test_single_directory_zip_download_writes_ready_history_item(self) -> None: + (self.root1 / 'docs').mkdir() + (self.root1 / 'docs' / 'a.txt').write_text('A', encoding='utf-8') + + response = self._request('GET', '/api/files/download?path=storage1/docs') + + self.assertEqual(response.status_code, 200) + history = self._request('GET', '/api/history').json()['items'] + self.assertEqual(history[0]['operation'], 'download') + self.assertEqual(history[0]['status'], 'ready') + self.assertEqual(history[0]['source'], 'single_directory_zip') + self.assertEqual(history[0]['path'], 'storage1/docs') + self.assertEqual(history[0]['destination'], 'docs.zip') + + def test_multi_mixed_zip_download_writes_ready_history_item(self) -> None: + (self.root1 / 'readme.txt').write_text('R', encoding='utf-8') + (self.root1 / 'photos').mkdir() + (self.root1 / 'photos' / 'img.txt').write_text('P', encoding='utf-8') + + response = self._request('GET', '/api/files/download?path=storage1/readme.txt&path=storage1/photos') + + self.assertEqual(response.status_code, 200) + history = self._request('GET', '/api/history').json()['items'] + self.assertEqual(history[0]['operation'], 'download') + self.assertEqual(history[0]['status'], 'ready') + self.assertEqual(history[0]['source'], 'multi_zip') + self.assertEqual(history[0]['path'], 'storage1/readme.txt, storage1/photos') + self.assertRegex(history[0]['destination'], r'^kodidownload-\d{8}-\d{6}\.zip$') + + def test_download_preflight_failure_writes_preflight_failed_history_item(self) -> None: + target = self.root1 / 'real.txt' + target.write_text('x', encoding='utf-8') + (self.root1 / 'docs').mkdir() + (self.root1 / 'docs' / 'link.txt').symlink_to(target) + + response = self._request('GET', '/api/files/download?path=storage1/docs') + + self.assertEqual(response.status_code, 409) + history = self._request('GET', '/api/history').json()['items'] + self.assertEqual(history[0]['operation'], 'download') + self.assertEqual(history[0]['status'], 'preflight_failed') + self.assertEqual(history[0]['source'], 'single_directory_zip') + self.assertEqual(history[0]['path'], 'storage1/docs') + self.assertEqual(history[0]['destination'], 'docs.zip') + self.assertEqual(history[0]['error_code'], 'download_preflight_failed') + self.assertEqual(history[0]['error_message'], 'Zip download preflight failed') + + def test_download_history_uses_server_certain_statuses_only(self) -> None: + (self.root1 / 'report.txt').write_text('hello download', encoding='utf-8') + + response = self._request('GET', '/api/files/download?path=storage1/report.txt') + + self.assertEqual(response.status_code, 200) + history = self._request('GET', '/api/history').json()['items'] + self.assertIn(history[0]['status'], {'requested', 'ready', 'preflight_failed', 'failed'}) + self.assertNotIn(history[0]['status'], {'completed', 'downloaded', 'saved'}) diff --git a/webui/backend/tests/unit/__pycache__/test_history_repository.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_history_repository.cpython-313.pyc index b030f1302d4afd47a5d11b8e157f09561e1ab9da..02ac3e2906167ef60113eb4be8aec7457cf6269e 100644 GIT binary patch delta 14 VcmZ1?)FrebkcrWFa}LuEHUJ{q1Ze;O delta 40 vcmeAYS|YR|kV)ECzdXMvySN}RIaR+rH7PeSFEKr}NH@2vK!0-((+)NOB@qsI