From 12f8a3507b6e63a8703c8c8fe29ff026007c352c Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Sat, 23 May 2026 16:37:57 +0100 Subject: [PATCH 01/29] refactor: improve exception handling and code organization - Replace __class__.__name__ string checks with direct error.response parsing for boto3 ClientError - Remove redundant if checks when re-raising EndpointConnectionError as HiveApiError - Defer asyncio.get_event_loop() call to async_init() using get_running_loop() - Remove deprecated pool_region parameter from HiveAuthAsync.__init__ - Add HiveError base class and reorganize exception hierarchy (HiveConfigurationError, HiveAuthCredentialError) --- .coverage_full | Bin 0 -> 335872 bytes .secrets.baseline | 32 +++---- src/__init__.py | 3 + src/api/device_registration.py | 18 ++-- src/api/hive_async_api.py | 26 +++--- src/api/hive_auth_async.py | 76 +++++++--------- src/devices/sensor.py | 26 +++++- src/helper/const.py | 23 ----- src/helper/hive_exceptions.py | 72 ++++++++++----- src/helper/hive_helper.py | 14 --- src/helper/map.py | 7 +- src/session/polling.py | 19 ++-- tests/unit/test_device_registration.py | 52 +---------- tests/unit/test_hive_async_api.py | 89 ++++++++++++------- tests/unit/test_hive_auth_async.py | 27 +++++- tests/unit/test_hive_auth_async_extended.py | 61 +++++++------ tests/unit/test_hive_exceptions.py | 93 ++++++++++++++++++++ tests/unit/test_hive_helper_extended.py | 32 ------- tests/unit/test_map.py | 15 +++- tests/unit/test_polling.py | 74 ++++++++++++++++ tests/unit/test_remaining_branches.py | 6 +- 21 files changed, 458 insertions(+), 307 deletions(-) create mode 100644 .coverage_full create mode 100644 tests/unit/test_hive_exceptions.py diff --git a/.coverage_full b/.coverage_full new file mode 100644 index 0000000000000000000000000000000000000000..6aa88301b4fa613ea857311cec0dd12aa4b5fd1c GIT binary patch literal 335872 zcmeEv37lJ1)%bnM%l7h;K%tbjlujw7E!}}qpcLpbU1&S)bUNMJkV!H#X){ThrPE>c zrGTg?f}-G#T9;4xLz(KN;^y zX3n|y+~utI+`isTnR2>oFkc)=l)KI)ClEm(mvwa!LPGH0Hu#VI3qV6;0Q|pTYCAPU zWZ~^!j>L8l*}sd#4n(HJ7RW>LAH#2vF9`LAGlBV`h44k^Uxx!74s55@?Mf+~LYW1hfz-lZTuA>OTk%W^{;HfF0u+T}W+YJ@ z>pGMkn@@j1qgGBIEvp{@l5}P$hriJ^NBi@+UB&cZx|q%-(!;K`d5`)=5QsgPyYNa<1gpaVdV1jyKoS&0%nE%lP)|-Ah0UaM@xsZpgP49m2#fK zahUx068Irse_}8&Y5H{G<%j7)LH5K4GUbxq2-Pea{bPrvgWo%^8{8$eiv)L3HrtgS zq+166x=WoLSk2}=vxqSxQN)ebmDhd|diuY*+OV_GhpvHaexP=((G^ol!v=w~fc@HF zC6~mlQo=DL^GDLf#87&n_N{s>{BJKLSUO8W{z=`_otBQi1ma8drld^v2PRFODpW&s z8ELbn{a>JF39*^v9HZ>3k#wR|DYAp4-XY2U!Z;<_9?`ym;VcOFC#{_7G#u^s;!Ctf zPzi{ENfRduin=54T*Ci+HG{xUj4fah1n#?ENR)@W?0v_Nq2sfC{z=OxI&~bo5MP2n z{nwx1p5kyLLyUyEemI`DgnCi7gGuN<-|a~ zQl{${LApFq_89$D5g0JzJ^a>O`s=X0x8_Tw42V?}TcwNGr(Ls$6D2%_O0&BL^7(8! zkyB*@{KbY6(9D#&XdmoS=~4qnX$)};9hpu0#DohT^V?z5vHb;&k$rO7K4!zxxd z`*Cw39rX}_KkS+fo3aF3)Pj>C)xO0Oi{W2b0G=S$kwi+}?l>tXjHAP88#+}Qtx1&A z<;+OB3x9=nhAynpM5!xTOv9I&ETtU-jtyITrJ`96->@@O9nIX?-g;{Xr#QX@f1;Hf zL{(0iE(9jcn)3B;3X(K80=*PsFA(@&=U;~d9S(Fj(BVLb104=@IMCrhhXWlBbU4uAK!*bz z4*dIZz$BcG3CA}7h8kcZ{T;m5+)g%?UsN@LRO(04-Dht3cFGWdqzy1-up?+UEm|#LYaa^BZ&g^ zZTD0_wybSrsq~Rd5~ihGE@lSc+J6a9_f7%Svs*{aCMO=x_g86SMv%+PQd`nPE!;G5Py z|As3AfUWmrz}ChDv(XOc%cF3;73ZVpr12RrTq7>wfVWNnd~F;MdKcmTt$IN0JrOW2 zZ=HqO?o$!Rhv6!6HeHP42?yQXaRMM;(l&Cq;|#aPB(aPZq}tP#>f)wo3wp zOIsf%BGeX7F+G$iL1h>emw@#=6Iej{^wZ-0W$Om59{T#|;9Yo76fhO{g&b-{rfX#6A`ql0PX=iB0g`A30gh zgkKh36#8!PjnbUZ4WVVhUj^3({uX^u`cCw;zy|5c$ie8L$QS%ChQA$nxBqVcZa>xr z?ELF+pu>R<2Ra=1*K=TtsLT*r7XXHkQtkh)UQwCV20mtD8utJ6K2e!F4j9e$|LHv< z6x3;b)U219{XcD!s7!4Gou)ge_Wx;nL}gj~$TW?##{QqWO;l#Lftn55B=x6k6_uH7 zpk(HpZvRhib7~ku&Hg{N&GpUDG5degIN@XV|0$bAW!gCKPqqIiwmHFUv?lxi zv;R+O^MKI1sN4S&_KC{!Hdv_bJ{7Um{y%Z2s4Qt8xy}ARVVw4jZvV%|Y3faTJGxd> zV(pAwwf`e+?r1~APzy+IlXHl!#{LiQ7L}!K3=`?rhAym4ZHiW&@SUz_KW)<@I!5!>vhvF=Uwzqd_R zB2rTyJsU)2QmA!B$b`xVx~d%eBxC2iQp}a0d3CXpTRk6;hnq*9%0NNOvW8^yJapi=L$Vnn zZCVG_EoDJxfaM0=EQ62B*Om1`H==L;}R0j7XRBzSrj$|O- zmK)MXnuF*c;vJj|czYY;MLuf0>&cJyKn?0m7&y?d12$TclCl=&BW#kQwdD-JI@1+a)6S?tqwZf^ zX^8N^3_!Tj6~gUw(b)F~2fMMp`Z0J;4;ag7V_CegNw&~^2Vnv1c}bf)He==CVmcki zUei6@i!pQ;%L~w&_`o|MC{EQdLLB~7vTzaXUBf}7 z_m%Cz{TRU4PL1?Anwkj>eMd93Xk{*?9vj-&Vatsqj>Zf5LM01JA{9>zg2EIZf+d~I z!XqPP+Tpzz&eu-VrqDA@*U-Ci`O$a|6tg&zD_)ZjAP8#|Rhq zHt5Bs%m}BkB^U6Vq*#Iq~1S={zY)6^I zJb;aL`0PAznvO)(0@y1|z9dy~AI~%83b`A{?10rR5ur|59Xn-i9MXZ}14Yc%g0x5~ zDEDEez>1a#u>HdZ06I^6Fq6)vN>`WDiDD{0n%jw41N9cUGmq+E2fpma%mMDk1iO0H zv0nH&^Vm7?bH=d~;OF$}LHIeXx(0qutxkuZla(_3oTO}mpC>D`;pYSeevlLK*}W4M zc;IJ5+Jc|ViG3ipIr@j_ZPBwL_eOf;@5#yVi{Y!K|CY9d{wl4N-WIwmv?=(9;Az2^ z2R;{=9LW0j_&@2}DE>uw#P>GeKZH@wGSBNh&v|e2{?>b`xLSO@Fo*n{+=!?f{iD8M zb#6eP9$))^!5UEs3(X_1vH#~&2aY==4YYe)?enOAnuE?_f;efeOvJGj$;8{)L!#2l z8>!}kb>e>INyWGXlV<+^^ z_Wv}Ovk1srvj0zW&l{&xw4VL12Howenr~OTvj3;h$U^HVx%U5LhvS0Z&l=5?2@xmz z|I~w`vXis=ELI(CLltcs_Wz_|QRy28QVs3@Q}&9=g{}yxmM@OXt^GgIVSerW;^B6% z|4(iMsiXaW63q^AZDjvX&?Pn~dS@wTiq#Ds6+1j$R#syAMMcE8k z$2~wxqn<^^fvJpiY1+AZ;Rl~VLnj5gfHNiNh8imp-T?Hw21KR15q*99xFCSxTNA~^ z2ph5%Lsw}hQV{6UKGFQZ!YGhA{jjJcoyq6~*3&ChYuSU2I#;tv>UD`%bTbb|L3Jo_ zJ;WhL4BQgn!OY|em9jd&!$9@4f~X`MsIuU!owwW4gRr26x6`%4dXUfMAQ%XNfOxuC z1d_!>1{QRj?lAfZozVqAbV@>0Ryz=-nGmYilt+ba5xRUzhcX3NKG=&mzG*pH1n&f; zf+pLkBcig`PDNdx_A$Z4IZ1FJru*1c9m&jNN|AgYD4qhQ#TFYyZLTdD6uNU<-O#vZ z(2s-f_~vsP)8IVE(?O@R$=I)@2WsB!kwqiHF22FOV~<0ef`Z zb#1)=PtbGF`Pbnmy^4kw_xaAK4ID5m^$M6`34~LDgCmoiO(srp^I$xSCohpSw ze-Awy`eEoBq0fds1Qic%4OK&>P%6|PS{qsxniHBFl7cS;p9}sd_|4!0!Fz&t1#b%; z3zmcF;Ev$B;03|C!70IT;Kjgi13wNt0(BBT9C&Bob%E;xmB3(NXJCEc!ob;qsR7yl z5C8A{Kk+~6|D69L{&)Fb@4vx+#6JXYDQxgx4&`i6bGeV6-|`{w(m`J&=W;vdAP#K*)hi1&)e#W#pA7mtb=agVrByjWZyo-V>= zH}4<4Kl6Uu`;hlO?|Zy&^xoth^B(l>^=|U6@GkUD_nzPtJb&{1-1DU8E1pk#KID0) z=XTFco~t}LPr|d^v(9soXC7T9_(QHne1ga0@e1?E%gD>5stlkY0C?SXQZ>wgYp<26 z5(BQeMyiGwaOIU!HOPRmF{v70z|o^p)z5&@QK{-yl5t^)x3eKvVBd9$osYk)6ZBP-YF;H~)W)h6&(S-DCFZy~qH%9T3! z8uA)h8Pma=$<4BIR0m&4UMVZ12KdquSvjJEuOP3Gm5L7DL~fFmvJSqSyj)gF28e%K z)WI9^+ruXCMp-H7;0@#kS;=eQT=E<88(GO2;7jvmWkd&mNq#9SSsi?uJS{7SbnqAC z7qW6t2Y*g}E-M)we2P3JE5kbYQ}R<;8PdU@ke|rPpbq|+{8(1fI`|{r_6KKZ_^By{k5x;m2F?~{3NvM8mjV9sx3aQR2d4?sWMzjAo+g|oEB!h+ zRhTL(+coekVY)C~R{9JOQEk(~)A8G06L`9;Y}LUj!W3EAqJxu#$+FU;gQp6o%F1RP zoFq(=l}$Q$ig1doY}CPt!bDlQTmxqbF(D=^8+0&=;Cd4nm6dfmC<_3t)xa}_`Tqny zckAGZ!ilo7#sFXP%gSmU3=3gdS*3%LAj!%~9SjK}S-DIDX9^311+sFf4$epL5)%mP zd4&!}govzMY=B62xem(s?L{U~mX!;2Fen6Nh=03C2me9-Au9`Y@I~^XtSr#L zGsvUlQCXREto;n5@h+z?UwRl{0kko8+6aGD8QyLB1g?T{`%6@^x96u7h7AUz3&7 zb?~d?tFkgp2fsqTA}gn9;OXRH^02H-HNcnF%E}ZS{2ck5tW4Iy2g!r7a;gqKKpv2l zNjmsh@>y9qMF&4aJ|im=b@0>V)3S2122LX%Cm)xUlML{sK3SQdgZGkqW#vR2{3!XT ztel{OA0Z!+m6#5Gn0#1PqB?jFxkpx*l$}OCL_Q=dC}mN2ohHl?=Ew@lSrwcu%$60D zv=k)oC-0XPl(j1OJ_J$PGVpz}g7Q`cj}rh<;vzVeyqCOJR#4`u;Csk>WCf+J3LZxg zkPpZT%3ck8DJd%`eO2(?tb~-JgoTcqlT0$YKwo?*HSlZr`l2E*2`_zE}31us7ClV40R@#ooC6ubPec*tE zB9*rH@0U=X()PZ65(-n=-n&;qNlM#$_DU#5)%G3F0fY63Rr{-nLCbfk@lEy%I`8+TOZV zLQzQDTee6j2Wh*fM?xV;+nYB_C;@4E(xLSaYSYt~38>1Z4HhGI@_ua;28(e|oU5(+rlUcOvH=|ZL$69P#^n`fOB0x3nC zvu1@rLeb`#XNEvJ(dNvVA&^Y8dBzzbkV>>UV@3!h5^Z*Mg+Ln7=Je?ykVLe3`spE% zLbN$;S_mW%ZJu^o2&4~fPMsP8$wQk{rbr-lLb(6W7S0ZV$O(C1ot^!x1mcE%JWH4* zfvlnJlZ2Bb5H#2(eoU$d@D#F0}ng@=0li*1ntE zErD2}pTCv7RRWnp+sDYUAPAHYusW9uf-J$-;>AG_BiK6i)F4O@Y@KpSaEjWRI57xv z13#PiQV>K2wkAvng0#Ta2`24T6loRwNPx@qn#xI0%vfTapw6p@6MWCI9` z;pdt+FM^*d?>q^9UiyZs;pd9iXW{4a+wj`Ug|Efi|L4DE9sE4+=8NIyIj>v^KNsJ$ z6@D(f5iPp;*PjeO=T#$kndFq{A^1tQ#h#0OFZPw#Ct}BAx5loCWn;Ty>tg4}&WOdL zFGin>{vi70=zY=S(bq+E5-C3uP8ZuuSZ>*O2dF*z&8H6G5ojiZ^AzcKN|i*_+#Ps zhu;#uC0qr2e>l81+!J0IJ}*2wJS7|n6Y0;=FTvXXy7Ykb5$U*ehx97xTCnz0(oX4e zX@#^{njuY)yrI8@o(??`dN_1n=y>RMcBT+ zpZ7oQf5QK;|33e5|LtJsSNv&zzrWkR)IZBV(eLv;?|a(!gzsVBeZJ#h<=^0|_|m?9 zU$<|mZr+x@9p+3_0IB6^!hx{d!F_@;d$6|pXWGO_cwSdp0uam)9qR6ndO=2K?hLG zkK!8lD*+Rj{G$fGmAq(z!3ZWVm|(Di$=^*dn8D<~O)%KO3`Q||)&zr9Onzg6!7L`fHo;&QlV?mY7{=sRCKxPZ@=Fs8rZIWi1cPl%eqn;a zI3_JOnzj7!A2%Otbx184@@vv z$>jSwOwDBSJv02z-;(c|U@(-)cT6x?%H&BC45l)9LWilXOddDGfbZKT7_4RTSPgtS z`IZR=dzn0Hg27-WkCy$tU@)u6`)lCOllSQ`HLS^dO)yy2U(DHa2;S8GdOkd9w)y zE1SH@1cRAP?li$*XOlZjFc{k8jV2f@ZSn>a45l`@U5BZyOTGr?eOliN%% znA_x56Abn?d94WsgPYu9g2Cb@uQ9=3a+6oK2o)&znLevJtPBm8O;2v+!2GN3{&AI45dy@gIS+mgqR&Ti61gzd* z0IOE5H-P2K*BQV?7p*mb3oht3fMv_p7{K}GuQq_COII1dx#zAlfOF2d%m5ZXb7|O* z1}`^%UvE#DG7kn1 zCN-M!!r5oT7hu*?yMC_z1sL{obO0>-*$hC_9t@4G+R4-o-hFnL_$_+e{;$@uxoCS&w!6E(>^+RDX8{8 z52O8jiPpslpq$@H-X!gZ&o0(bPw@6+LRjSPBJG3NN%45_4*1{#?XP4|iS8%&2R;R# zEz^D}>{-0nkE36z{gOmJMgC0w>c>uR<2Ra<+aG=A14hK3M=y0IJfer`$%{hSg|L1t-kl2fnd9n4eyQ05|z9MpMxKOO#Hc#HH0PW}H}U^2Y_x5ppw-Q!y?{#v|QJkR?b?{(h!o^L_^_#ELo!YI`L zf0STeg7z;dDjPVpciGz~dKFNpR_V4nJ*)YOB{Q)|;!?d5t>ta6e3`yQVW!bcaqorW zT>sx?aqN~$oR52*NSDRUp~I`_gMX~JWt~Fz|C(y_4)w5yon9tj9d%JYzgZsWj~+U| zAK~x)Cwsp$UyDt~-R4KP^ zDgFPchd2Hm-!5=kD0S=eyF5W*v9$KQfJ5DNeqDWg{eOP3euoppEmF>-f>RvMJ3h)z z{r^t=f6$Wtb@l&iwXO#52{e5F??n2#x4oFQ9k2PF1}0y4@Bf`(5(C^v+?e9o+WUVo zlL%`HJ=1~fz0nsnt=*=Ovlqrn_`fb zw&yJ3eblRX=R%hF%VO_}T^jvm^p(-`B0q_|I&v=L;~$n|;g5uOgk#cYq`Wje^jN4E ziUj{N*c*5*@an)^$Zb#g{^EO;Z@&15cuZXEIZODua70){9wC^P0RQsv5QoNl^NS0c zMP(ZP$N8#|tqhrs^**{d*>fCD57F*-&lX?fJb_yYU9YFr*?>^PP(`3imDWS+wY;J81W%-s5K=b6Ndc_-I--ULrebkAw8 zrohpozYI>7?V63EGMTfdI4E^5h1y3ms=+~APTeN`%ei~t^{5fihzE8lXW%8+$M}Rf z?dYW@-LXmJ`V;8sqv<3(@j{=b(%GQ){W6C)&}#VreP5-&YpWlJ_~H{6H9S{tdkJ@? z!)aVgXp{P^4!l64zRgu+Z{XBCOhezOslX#Om0UV80IDT@0cvy@-YU{xG2Chr?aq8_ zOs|kA(KnY$N8oiUU|td2RCLkzI_!UU140FD1mDflU)#Zh6<%B_m*R)g*&`V``Rm)K z0ndlmr?J7+0&`*%j>CkXiHea-F%1tD!{)f$q{lTs4;J*y$YNxe1Ae2c9oTpb3~zC0 z02WsvOTGJ>#)&~NdgGmP3WOl^3ag4W10 znHksFY_4#4D6qD(fQ(M4c0w?3cPc%YfVcW^tKun&OK`1&wpSNl(n;G}=inZ&VY8i> zPRzT+LBAWW3{YhhT@mq`*Y4G4+UOH|*?jU)I)%eL-(;zqcN#;p9nh=?#d3XRpbs7- z%ol6L(Fi>H21inXx{0vYx{Ko(D*# zyF+5ynkv_8U-!nvzT<2_w}gYv68CkAj5!I1!MT99w=rJiqvj0m$&dEHbG(}poyLZ^A77x9?Sb*^eZ06?hF7o?0#E;YVD3dz3O~wmat7k=GB3 z_nrlKXE$b!9WT>p*xu3ifA6d&QL=0U=d_63X9CJ)P4tqXY@EQbeI}q@&;n}p+%6{I zIp$g`8K-N@8Gv=BE3BrS!8!GCr6IxtGXUXAR|s43Vf1$ae)=eNZM4ejBY{oQOo~wN zbU^E3c>!7zA9yE(e)R7@9Z+7{m=l1~i4*mb7WPg9^e%RB9rUmh!1XYYNFKsRYmtLs zGmp_(JPnY$m_bT z37iqC*%ybyQ#&;9L-mj8cl3$$65aAcrvRzG_DL}(o{bXoyziL^gf4VLXeE8_8%M_U zY=>~QE)4ok2Hc$aweyRII~PA^@nU@r45TNuj+DMmZpO;E&@%y$&S=O5Slwz*bwdMn z2EC7}U5<-z$BBTCqs~}HZpG)o&|-Q79y~|9TTcMIv)uI4JWp(0^Wy*ep(HXpQMo}} zrWUmp|KA%0^xBEq6nduV8hV#KM!zQl$hEcG6mk|})GY!#?%gur){YS_?$+b~JHvqb zTy+wgM_niWKOzBQcpkk}E-h*vantet-Vl(}Bm$QlEbk`c|GmMMQ98x{djqYbbcp{a z{eW^~9X>k`+O z%%eKkQNDWsalMV%5@L{#l>(MtilF^J4{G4V-WS^uBhe2;w?zILxh=9#{+0Ynd13f* zsH-QB6Kx6FT2sDdx=DNEu-CeNg=r{y&ul>oaB2 zN-STba~R8_uH2nFjfr)n2Xmn2Cz!=hE?*o01GhX@U`ErBrW8@xwr>X@*4ATlh%KEh zg5ihuv#NfBn+hdoUq4{y=pr?2>SLOlDYS>%2P2-k(q?keZQBk==QiMdJ)}%MGvVCc z2RJ!4X-!9gzs;pWbZ-N!u?9WGSu?43Cf^v-lj6OAMmvKM7qZ3P0H z)!ZzBa(bjdgMSS|vB@n!Pdmz+9}uozE%<~^A*N@sW05(B_V)l0j@9aJP!=K#xe=v1 zuo=*=w9jqR=p6zqdp7}oj#b<&e%AAv2xfBQ_)GwuaK{O_e0(N=5@EwI;>ZIV0R4t` zCSastD12aj+cX-6^f#>o8q*q$15SxexWOCQKs{>#+l(f$F~=e&RC;+g;G5hCAC+{v z)$NoP)4K+6ahw^oOInqh+Bh3!?d)0&D3`kG9&=EtM~AwobeV;gy72{yu3f8|#%$!2 z)lD`VS_zmrUWQsWfB>__i#gs(CMbCsHv2CFLJdzqo}{$NU0$Zp-b(>}!xM^&-Xei( zvC~ACt(O3Dj?G=mB-j-;COI8P8&?3vsg0HjU^Gi(CuCP#49L3e$lOI=-9)Gp%YnkW z@t}b24{n%!|3yH8vp(DnqG5!3*M)$dvp1VWPkm6jT}30*doBRX9QTDFTb8*K)z{qj2+AzX7 z+Dz06?f*IO+Dh!M*jkAG-w<6E`3~IwKSlnGd`O-Yekhy_p9rtz9gu|3yF-@+zaKmt z6aw!ET;Tt%|Cs**-=n@Od;xfUZn^hs-agN-JVoI}u>55}I8OgE3W0%hq7aY@T7ty) zcq&s$g1fjlrtiJg5N|K1Raaa|<1CAD&|HXgv6wGnoGPAyEvh*RxmF}qUz^l&c`*DLL5$lEW*`X{ z@>h{^F_FQ9*-GvZID9d|6+f*M6YRo3`5GASNSi$X%dLK;DQ2$VYZ ziPj02lS-N1p(tGqR1dXDl@1g4M!Zr=7jub`bX;}Xz!eVU0J?Wdm1Hul<-Q)g3W)A@ zCW@WY7Z9fAZA%YAR>JUh%3N(~)x^J|`VHh;7cjM!aoHH)kt>1L;WlY8Ds*;rT5xQc z!mS`VievF?en?GG&W!jkpYwt`2G0=-h3<3w@7XqqJ<)l4y^ z0Xw4RUeO7`t-o+6-oL>ZzT?l~YWsYZFoKJOU^=0E! zQDvBoNx4chlLCCsYA4qwoYvLDF#0s4U1U%11Ah)eUDgja zqra3Li1ff=K+5qFHAKo-Y+?d9%ZoYK>1sw;mZmoh7Fy)9I{0)6GNXBGhMrv7M!<2z z^FW8=g5-{)zmS6cUwdX{AtggSL{ga~1m)9^kd4MYh(DS@kjnu9x2Fb&fVSf49v+LA zGjI~ChJ%_Jt&&bskxU)xLnA%q^2FEgTOM?kQ41o-0ieTv^%IJnE;{w~8afcl3bCxog%W6DQg})wGW(PR^ z7?CH20XfH(R->0Xa}YPpgV)d|jsPnX1DR|FR0|qFQ5TbT2o=**<5MS~X9!5l;7v0o z);p4Lyi~At5b$wS_Xb_A_T@j4RQLdWdpc5>RqNJr*w1pwv3d7J&w;_p}h_)&bX? zHHBqZM|s&CI}?DBE46C|j?2r05%gf>sK(HI0d^bfGYk9-UIBbL5E zz+a<=MG58$Z|YX?4lh00VdoVy11x)MXPiHPhIHszuFV~s`3baSis?c&kyLlZfdhbe zrF~b_MXc=!&wLPVR|hPHV5UjiSxMn z_U;85oI|vRhUx;}qC3=aQ_xtb7R*(ZJPcY{yOg zvHN!eYL2ha1!{=lT4Kd24KSJY_%`1EKg$y#-p_cCcu(gqgy|LBNr=vH6C*bGs zPTy?#etBQ`zu}#|ozh?79>B)Xe}N}pUGS;kHNmdHJ%P*okNfxfe(t-uQ1z*afpaJjoNVn@4b)S)g~&FaPT*<9YCg@g18hl_=E={V{D|9_W}aJVzZT?J;# zuAVtobh!1}5UERZ?C|BHGD$yLjI^MJ`0eIs$zjPhM5T#iw-$7{RXR;tkcClEIqa4- zPOdNJ2vW^C=CG;jj=J)wsFd2J+lqVW=pj+r-7aC5zP_uPkz!8FW$aX?Gh;Q^=Qh?9 zz4qNnm3^YJ+?kiUdF5n8*EKy4I^3Src3MN^ph!}Kyq@iRC@m_R+Zcd5FF@8Mn_4oW zGku~mmpg@Wwpc&Q3PZY=a|7`L$61z2aM)aQOMUfvw_C$#MK6z32YvGjif^@4iKlHg! zGWcTf?ZH)$`F~|#3f%kO>HCN8oxZi=v*In{67Q$Hd*GG%TcGaWLqbaM0lIPems0R9 zxaP#KdDL_~wk z+&X@6C>PPstmRdhDGVxjbzF0ZHl(ldlkHtt~pX0QdRvr{8dm=@0;lqsUuavJL#GywPozO+qMSxE(Nco zYk84v;{Redx)wlb$dS6JEewe( zcrV@Uk%VUj+7T4uv}u1m^(4DC_yiaG~5l#Ya0c{!a+rBk|$ zUI*1L5=U2Kt;OxoT}@R1>rYL}W3eI7U1-|l9tE$eH-j9KX51oADR^()rd_B9A+P7F z__ZE#aR}&PLIr;edlkIKZaXmtKzE4`Qx}a9rtVhjgHq)dl7EcMA5`YdSNZh>)?o#& zwA+?ZbJ)$1M0B}PwUNz*PHI5G>+LqN*%B!hFSijHQt+C4pLJU_M}(z&;#eRPld4QM zN`nesdbcSxTuPf01@zu+&#z~xWa#^(bx#V3Vqyf-5L0nl22i6pk|-3k%a|zzufW^a z8E*mD2{E@iD6Xb*eJWJRL7EaQ5}JyVQ1C*$mTh6|awFobZ$i4fR_c*xZ4+vXATaS5t0^A1AW%LZQScW0Ve9NPO9SR)0R9HDcH>lA#{I)Sz^xz8k7OWEN&5&1_bYf; zo~r+)MRf;w?<%#=8owB72Y=YL3f!Bo3N}{J zx+2_zaV7m~HI}>F3^Dk%u)@m2U~>))YZR$Bhu*ui$LBE)4k>|<4w^qyDIm-CDR`%z zbH$jc_=P-8t<;xAKTRK71L*`BJ|$k)ojeW9u%mukRS}DD$9CGD-={7N*6I%!!%Hq3zAjv z3jTg)itaMdhgBUXJ_Hk5V7DAFZ3E8>QxbYs5ypcY$_q!5CUO}6Kf`l6@qWU)%kzw9 z4EzAIq8|c3!2OZSgl@W;R_12g?!^k@7b-+RFe z@N4l}5s-lOpN5(O?;?8uX##@%hdkERpX6KabuC5+9bW1dQ6m?C-FPO)q2}r8w(-NN zax$LDyTQS?qHPLJ-Uad=C`fv)TlCE+@#IeM;4U2(%;4{?x@Jiu3y9nf9{dZ( zg_PX_BCm&d(#ml`t~snMrYL!1GiOv?Mrj0W`|*xuPFY6^IMG~Vm%PD&|Bgmt6LuWA zty$zvSfJ!}gAhTsP40LbiR4y3nQ^ue$g3brt=B#pcVwEddC1KfQR#C>$kFI!N_$;1 zj=UPeJImXoUE74~TBqcd@Sg4>H#C|v63IwM{>PM3vY>PU2B-!kQJ4^nzheEZ&zqxgSC2j z_&P8WtG;%rwRL&;+A`R8?e?JAYrI$2WRTm_JY6wa^Juxhv3w0A6z;6EL+n&rzJg9( zHZFFNTkD@FX#bxnJWjm7^}fnG&+}01olpnh*=QvyN8S@zDgRU+lcV8xhgV3ykX|pX z3H>>A2UG$0bMO_xX@L(1Hv6CS@Ap0LyV*BG{Fu1O(<3}CWXT`MD_n;E%~Fr6{^+i? zA&9A~7uR(w4UXD1bPi1pE9Tv!F9t!}Hx3sMQ+Qht9@{|g5xMs&Q5m(Jwywymthso& zF+JA5Ctb{Ip0Y|@b;#066I#xo`U#B*P}*OEgx@XWL|Y@v#=GQvaG`;(h0=Lq3=(gf zq?5zAQF?u%G_3N}f*}UfR<4xrVm%>4S0xrsFXiyI@k>#Z9VR;&12+B zrMWtrfvM^N_pe4hQ5wr7!GE7oZ^|+UI~W+~poTS0Lqy~e?$UAaG&OP%Ip&Cv_cmi8 zH6rtG>4)NK{BCDon{(}o%3vjn<#=Fi=q_RPrY~LLUosyYdR9=AlvIbawwTn&T`CXv zDp7Z)JD8IC%6O1Ml;~l~7c*DFD%Q>ic4-F66HroL=9*?rjtZi!g*+_cxE704>jNYT z8TA87=&Ma1MCu0JmNt%IZMxKyby``ZI6|-8sh%=Q=UZG;quzj4V~LXDh)S++6apY{ zeKOUJ^;(!Gjx%jo#fAMYCH&|SxMA4ppbWJ0Nld6_c!C2uN#F#zJXnD z$*dyp;JOEES*DvT4GB27|L=lJOdD-0xJ@FqDS0PX|7e8@PKR@jFL@W&OlXym$}@c- zJLRpCw{wqF2dYkat7;ylH9Yf{W@2RZQQKzatz7SRlij9g3n$%{ybbgJXOa6!?9SLZ z(Jx2$MN3xNV8C;pWctkOMVO)qe*YqhB2jVV~ad$bv{>zH}luK!?A z!Lr`AbhUazHe1)48skDk8dk8tw=JWvNkSYw-ELkDD_G~dT~e+z%pnEKecSR1n;exJ z^E9VmDR6M7;2Rb0bo33SHB`@6Evz&+t;~wL)fhlU1WR5P!9qX{r=Vc1@btfcU;-*A zLNOTi*0-5tR>6|uT2fS-V|1}3M-(hK4hhcVz*5e_WNtKFwaX}2gnZ|?(6g>iT20QR zMvf?0mVA812AVItWsdeDcpL`v6)4+i8(Uey>f}uwosP>>tGhf(K>)!HW9at`H+I8 z&-aeYGSV5PkCl;QiHai%)H9IOV8!&N zjvFnRl^&}Y%GR&8x;Spi3RX~WYKu6MGf50BiA*c=0QdGFik_wk|M?=G<3kFTS8qy0 zwde%NC|GR0sq>9T$$GS@&PI!0GofJZbz54xzMc)AqHZVgXw`L#WfUyT-u8GXjYe*< zl!67?ZQ1|}h%KJ29%TXGo6$5rM_1lvWqwN? z)KZOXuDoGo9^kV{h+5;dHP@BZP99Ru22!@Ob38~{to@WS7bw~Gf2)+xH0QalQp%iG z$TaI+#Q1-g=Mv&Adc&SOVz+Ag^W2n0CtM~qB76ZBP!Eu?kuyd~(?Iurl07PUAfil26u6k(G`hiJe#C;%C{;>+!S(A~ySPR( z+O%@Cn$4!ZUke&Or9j^rz)McFug`LW$n2tq?apDJ?THV3#v_y0Ug)F#NYKH{dC+|+ z{vVG(77~XS^nR^1L_e+^t#!g-GeQsKu<7j{y zO)4}fK(S*WjHOG_v1XK?B|j>PO4>G#dNS5gwbcHok*-cY%k@atBiV{r|KH6(;rfx? z&R4QhEMnqCmbwJs-l26}7KYfi4dwtvQTQrF{^<1_Z~TsyvaSI(yrWAPHcRRIbQyj(%A+d)BpFazLw zI=1hak}(apN;_0jnZZFYjbYv>QH+hj%}UxAO7Xkg07gp`!AjJ!4y@bxdreOnIA}+J zNtRjCcB4uKCKc`vqBW%*em~{L-|G&ghKhzAwNdIop=!+#vWs$!vg;@Nc=t%$(@?K) z4ns;4Th{aw_4Iy0Vs|>0vQBamuRP-Y&z@B zZqByoF(=<>Iv2xETN`K-rZoA4DUZ_FNcB#0N3t#)2IP}Xk8*1 zP|sFU&2uZIA&C)m#8e8>T+}ucx~5O*kZ(iM@$RO?WL>hQ1U#hxMYJ&q4lcxEw7URw zwe9uz_>PgSf7n~NrN2N_>pNjXRh;2sbk0S0J(LW5MWc(aDhyM=p}TAnyqOF8qdYm-L^~hR_c}6^Q+RD!4cB zx4^A|^ZeiRU+16adjjJBbHp!!_5V}vP2Odmr=b4dMZ&LzJA})~cS*s?2as0qfvPQ5 zpOw|J)=jImU_@_V(NKu>fFg^J!l-IgooG*5!3U~Zq(c?IQh5xY(!**TNEJ$zr~x&o zRWXdux@M@(*012}RW^^N+wnL?>3S4=z{=*^bBCAL(H;d~tZImm=Gv}C&03RN+N~!% zgHn}3Hj@OcoQ`~(f=^S;Z8Vck2&q1UKAjo|$rc5lqnggvY89`Tn2jV#huFmrsI-K3EE2_(Z`KPWc|gIJrdGBwOoq*2 zOgj~PXX@P62$Uf6zpSy4*=KNTvPf^uY*hvUv-aYY^8)2_3)zE8dZ{#;FQ%~X$c;V) zpPXvE<}DspR%uFeM>7STxkP#u(>qhn9ku#x>pEIDDfrOTv_@>T;&k!_Cl!2SYNH*U zv;R|n)XcqaXpe&LPT7jmjgJ!`so>jFO%0(1Ur9p2m#1t&S@%QG6q0(rlKl!kLUpO@ z$!ONuy;H$Qs5o1)&SXO(fN>5(oiV=qdB1`WQn~MWvo6e}f{#%(HOFShIdhRVaKs)` z@Kq{X_DQ`tZX*~lpx{GQw&H;Gs5v@J;TTiZ0_zc!S9Sprt@@Yw2y&isMgWSPG3Oy6^+{D?5OyO&e(26PQ-|0k2Ifu4i57DvT|ERnuqK9GUN+9#pmi zo!wTh)uYq8W2&t50kNjCb?Opx>3l0I+s2hF<6{GVSX$|Ab_$u&Xk^-GQ&;cy-VtRh z(6Lo|tT%-UIe)uuAL?T&_)X>V?pw%u9XB5ld{ zPclb%f%u>BU*n(aIquowd%tg&&m-OwyDPRP`m^Yj(YcY&MKY07<>T_U@E^n1gl9^R zNw1OSg}xIy8k!yadGI=@1@N`Nje(_NR-E8{0Nw)li|~SQr?8lOn_Q*&0gT!k##IS! zu!EY`a9vf;1=|L<%pt<9t=J*FS)yf|!d1Nzl)S{!t6GXO>b~T^`4Jj4R~q95tHOko z0W>R-BL6S$9T`ri?XSn1q8IdpQBxh!n5k%iK#d$F^iOWZFL#8rA)wx_y+UdvaMK%n zNI~%M(k04XCT|Gc(o8lxiy~BF!TMa*}Hs8SRAWx5Tz6d`U>1N=pVhUz% z>gP0k$QFt^lXL=K;>T6pP=WWrz|R69LON||2ea2?>68u>6HxY-@>_ssoMzda=(bg| zIghnr)zlzuBRNiCgrC!4?KcPIKqZrf7*(a1H9ulbi!am8%Bw{s(73apaO{LysZb48 z>#DYKD2^yRtTfY(!LJa7<# z;+m?!+tr+{5#(=;#z-8y6>=woV4U%eITb?%vRo6Qfw(TQFIdPu&= z&)DGf*-C-e>6&AQd9BDlTpv5Cp3LK|-}Q{w;^6g?o?`(+Au?SNvmM;P+LhUC?POzV z6vj>{C<6S17me_-NXlvInistM-OAysfu7gb_(&RV_NX|0@M2uIt3_(yr1DA^lrZ^B zalrnvLC|_!lq;g^samO4JFn#o#UZ(#s#+Yp;ZuZRzCg5`e0=w8UB@r^RY_Ea>>PE4 z893XDYo7dudm-K`nE0ciG#mKI$uoSF#MPkq-fZ$qZc>E1KDQQ5Aiw6XBFiXXd#d}r zVR(l;$G3Kz5L&{Xj$uObCvGhtD{u6UTJX9Kr;@)k$B-I2yl^D>V;dcBB0fw0%ok!# zj9&wyNjp`_#Ups7qnOV^Heehe$OBYwIHA#|8$IN2e1p>kg%Xrc%Pn5KL_Z3(2$$;X zz|)8Pl`kmVAcvg_4(_pnA${%WpU3$BJaRA5to<9J3nGt4Dscb*L-N}2Ps2Au?f-|R zLCG8XQ0P!-O7Mx`vEZeF-vwSDSn2oafX{pPdH&|P&9hv1 zO1K7Y1KjJB1<>B-i7GyPW)EIa@kVRLnGJnd``ubmEGF8A^g9bWXC;|EJ>7V|(A zpEI3u%}dq1fEMHReT%`!e_M?^40FywxBc75)ed(0rTmpL^*{bs@%uY znx|Kx6%9~_HovCDR8$<==IY5XwCVhznH-Z4u2eNhOZr)lS{X_@>uvHzPB|B9BcbIetYIAM)yM z4yWT`z_sl6Rq@rX70r>*hV8=N5wGGCU8@_S-V zUodS+9tGF#{N5@7B8?}PM}#-WD1DO3b%4`B2Wil8w0iP~(zH$mlaOc%F_sz8jzQ&G zpkk{{rHbbUR7}&{I4+h@t^q>p8qPGF6)k%VIDun`M5`qPx`sJ8bXsI;5L%9;r+X@(PX2Tp>EkHR~hF2?5qJ~}}zYxB{ z#ni8i0b*NPN(0104P&-@l%s&xo=|6%0JZ#Jc#ZW43sVQ^%EeB{l~F)$pH3Ghv`Y`b z9_0wP`z|On_5kctDuCR+R@@+W;Q^ra|2?bm{l9%)(enZD1N6qC(T_xv(Lm(x$a?vY z@{RHt;rqjV(yyedbXw^C(5m2L!J(i(@V3Bm|M&cPf5i7;-#+nq@fLUkaJBH5&`*Bs zWZ&0u_nRMJgPn!hMqBY^Rc5o(vmse~B~HB=2JdWEBX&0`E*|dHrdp%Hekq=JyrRXf zZD#NU!8@$A1`EVAR09(ioUrI>Q5oZRNG)lPrj+fjzdN$Sa2)3kf$Nv*$3WNAGfI46 zfp=xCl`(_!E0@;Zp`p=!W`mZ?1;hLl7VsT;bnh}t&^}s@?y8G0KApwfGQJJY9v6oi z`8(e=>+bMDFNQ7;@%+)xoqv}^6z&z%M(YW7aIFcZNk&~Dat%CTNuAUT=e1-b)?g)_ z!>#n-Vn|k%f<>?@1-Q`xXA`~GF$Ad=bSKW^hwN)MhMwjG-UPF)ZBiuQzCPXV^qPop zrjb8o3mh^IIU&{AVoKXF*#W{VzN_3-hv`c`a2!E0mOjVyGp0CFmdxaqvaaR4x%V{= z77I@j>e0mJKwD7Y)uEJTH#wFXN^ppyhf;G-I&f=Fop>m3CTb|SW7k`zF7Np|-3k2cUD|7B+`JxBQ8J;W zt~$Ca&9MvN#8sl=b66a;U9A!3+7#06owSAb7>at*)Is4m+>`n7e=GNDJtM9?MdSsw z{{P)1c6)4Y^s~`@VD*0hto|R%SIbkw9}RDkJ}YewJsR2_{A2L?;OT)k1g7{u?ceA7 z6})q&i+;dR)y>PQIo80YW`;WutqgAXrUgCTI%nk&qsB3XK2v#P_ zaEYI4=y-ovRfa&`;>&UwxDbd>x@5s}Q*SVX1xGa_Sk`J`8l2aGDi$Dj@c%kfHXL?I zR^f;F>@XEMGg8Px?P{pDLsRUosAAP|2S>aU5ldAI>ZZVc>(7i)X9L>rIF6%LEH++g zj)TAmYkd_v8CLaI6jbC^c@s z5TT%MBgBd?GiP{9iI?-np$?mRVfpLe>r+czG&baAZNmQ|txP#x@ z3AcS|=qp}RU24|GzADxaw|g4tS>r?l>^*$8N>5O$Edh<+q5*>J>91nBa0lnH6MEx; zSZ3$hjLKvcYlAyPILvq(mPgi?0LHMu`-LxEVyYSTK2gPz;4UTrA*5pL!Lm$9U6}ncq|)F^3!JO?rF|4QVT46{>$%BmAM&BTgea{l)B-BD#|*B@=0t zr0O_UDMM6B&6zhR7nz@mOjfZJxWnFZ?gp&STJN8(-f&H(gH@~wZo4hqh|@MI+Id-D zA5B-WRJiR5bR%LMuSfksm8)1q+;%Iu5uKXlqwNKgi@#y;;mRu37$0kX;n*YNcCw9v z54kE9B!{!2URGFxEyIo-SHTBGszSx`>g4gDXfni#YT%PPV^9~wh>MvU?5s6?hPyUZ z#d7DHo9zs&lxpNC1sA8e>mGy_C)8TB8MsC4)6-%7kBePUK zu+v2+0LEvH)ddW1=%gL0&ISq&I%h))rWJugNcAvSPZ!PMd`0yvpyZ&tH>Bjs9lX0b z3rINVmpl^YfP#Kl@jU?Bs%HZ3xi06L?+3s{9pn?b0jS0oug(OdP0X*k!>(8|Ts;FQ z*d7?DJ!JJ21RIU#J#^k*$3UoFn{ ze$udCRpxUwHjoo#~X(K#`Ksb(3#BC zMns2+LQ}}pNmyu>hE0)I?URbxDiIF4AJiIJZqIZlh>fBQr4ZBc{2+Vs3`#VoaMX#c z%D8=X!jG0gsWh5q+aYdYRQ2R*j)E(uW!*EaRr6Q4ko!U_cStrGY+{&wSZn?Y7u1Pm z)pD!B6H4`b1j0r9i;A`lYOYtKZ4bjsY4_TI^*aP|RdYSnJasRmT<)H_B(=0E-7#8^ zVA>@oIRq`vJWcd5$Kg4H32?foFIox%F3)EU6-(#U3m^2PN!0PBn->8Gsbx~MuGhHF zgk+txFEe^|!>dny7O8ubtpQpGMxSTZQg>V!wd-K7%LAu5;wBHIuzO5YN)Ex1)`#bY zv3p#~J;$+WI|to8P|lRHJg(}&)XP^Kp{IqB&Ma)NlOg14tPHP48BQg9mp*?4UTg&? zndT7{cDUTUjsxS7vAkc0Z$hBwhwgy>Ith_2jZ2TMSmv>Iaqi+@bhn+0ZpUdM#@&rO zUFw`FO@6@0LMK(BrEs-51?L&$JJk+!a~nhtc*xP zt9hg)D6%_($F4Ov1%61N18U;o3S3cE>pxuH)GjoWaluCJ6mD{<;@GE9V-|IP<3Nl7 zqL!UagB8b)SvrlBj~lp^*X`S`fV3y{=a3RQA^85_(!gf}`}{xi zr~QKO&Ax@=BjP?W;JwSc%JYclfF~rpUswh;{|0L9|6CO-kUKbq%|geG5$VQONmQ}? zxVunYt5$2iiuJ~md~c9>P<#i15ma++i|?;u0dhCNG`{uU$h)wuie<zg|SUmFmE2#=-%AzFAU0SYtbbJ;P_O-#H)Dv2uAI(K(ks+v#p?yxJXSnu2+ zC}|#vN??3V)wtW0U8rSSsCxCgDgh zMV-{Jvt4>OQdO*%?t+tjBPDRkxj2x~jzsre%_147Vtw=tjTean8LQOi64_D3YUmDE zoz1JFhKP&hw!ezy&t0fnHHh5{+#OY{a^7smbLmlbuEP!R1kD+uuRoXY=@%+Bc2%*E zxr-#SnvR>yC&p)HwW`)Eii%y&32ao%`N~#nX~Y|p-&}W-2_Bk?+-Ik7o>O^ zSFyUW^w%p82@;#~Y9Cb# zK9y8;ojV?y7m2Yltp|>p|83MMWnR=|buEx_p_lQ=SXL#~XVrx5n&UH7?QUl)FDRor!p5bsY9R+S3RdP9e!YrH57pSA}aGhJI zHQk|Pz%7APT1n^?j?}qW>5dXRg&pFWl{%MD-4W2Xb(VUwEh+AlTcdRxgmx14jm)_{ zpq;vnUEA=&ld{~wKM1t-%pSHBOsk-Ag0HaDZN7%K&`j9fq$mF$O){w;NVM!h`*d?2 z$A!R`slKCdtbQs%LkgCojVHo|s>?vkc&57x%jzFr`f9DwybSqc85al!ri#^^*g{|Z z)3gy8tC-^x+2%4Aqh>6wv`C@H<+iIq?OwAMw$>>WVm>r#@i*42g)OZhw#+Jbwf5Gj zv0?7Fi>zu^G<5=MJFt|7tT9L{=G+7l3NFV|ml)F>SQuBg5rw?VlS)J1>L_w9H;e-r z=l+klWKWF-?)s*haIwo1wg$baI-3!0*0wvIUY5(A!fKbBqLv7|%W7G%PF94l*5ypQ zrGjcTy)#=Yle*g?V--nGmY1;3WeyU5G*&YDSQ_8|71mrSDis%+sF^ZH^EJ(rYbAJP zow&4FI+#i8X^Ji1W)&{2bJ5;CSz{}h+}6S+^(`=6g)tAwq5(K_l8v2OFPGIR?&UNJ zs@%hE#(I^Eme)7DxFc}hvs|rURee3#B?*0zb7`zl}|0{(I=z%=n0W`MJ|?~ zk#Cb%gnt+=hGWu)rA?uqg%ZIZ1+NIa5O{Uq4F7xlt9(E8UFn-D-Y0JH{=$3I>+`(X zGefvX*hrov2W#X149XnC%FqrMC}1j7z2Bbg;U+C0U9J>!rT74bn5d`n7*>jQ_Eb92 zTFr9gS8DRQlo(8x#~^G2>LvqKk+_ZCi zqA?MUfu=1D?XfVp*RCmZII7WOSd7{^eAbwtW#h66N6h-yXff`poj}S%$FNwn!{se~ z!RXt7!%r&-wVJIqS}4oQa@z{Wu!^;Fpt|uGn-1<~j$zU3M!TWj;M{A*!^f~3w! zsBmml4SRw<4PX>-+;jREq8M!ef$_!-gb^b zTL#gBKPPbvi*Gw85HzIVf>&e5u>AI@W7wi04UQ^iVOmO!qSh{UT++kGuqwCf-D6f+ z%yoDSMMxveu?Y@Uu~@frxWqC{Gb!`pRkuq@uO-Kc8sfkPs#vt!wY)M@<6vZaRO2HH zM_yUQg5A!kA9aSr^f;p;i)E#N3TWeM&6Ny=RH2l2)#rBIn1^VmZlYx`h{Ih?$mMIR zQ0u!YjCU96p8%}$*ny!cRs(k}M$J61^SiD#IZ(y&;4LjE3~#tABeS=PwZfOT!cP}^ z{S{R#7T)a^J1;h*ZGfy(KxL?k)xurR2akkHFVcZ3mJVOp&S01eh97>~25$CxS%a3% zy*k(Sd{MmC`>gPYu#_BUwm#?o6-lg4Ef3TyT)yaqF#8Dn^?UAqtrh17^SLI38vnY zH04xK_lM?6H5ZY7L;dShmc!7jX0)KY3fI+lI9sT-dh`naPN^cwiDGqTi>p>Yhnrrq zhSqFfu5Z3ZRIX~iM{w9}ZBT8Z5?8Ca(drhOZf>dXCfr)5D0H1u#RYXH{&at&@LI(y zRs+osRh1!hv-GJKE4C)e!^}BpJZ4y+`KkRI^^`r~wMa?V>Wb5l6W=ofTlA+%GEgT0&Y!0TxR2 zPJo+kJ36ErXJ>s2ZJ%{YR$I)Y0o)}8=)!r$uM>Y*LhH^td zwGSU2sASVUJ)11Vb(-35U~-;~^uc3Ttk~xIYfKs{bgqX^Y{{(w=Hyb$V1NM+!^dzH z7_O1w7x8p10k1XD3NM9YSh3g^1#e6Y#>pXu+_ey6h6%OAaI+2_!)nDgUw9#(MQ4pZ zjX*#(gi3vI?S3nK%LW7>eV86C5B0W3HUZC=B7%4czr@EHky82?mL|3p&2>$Q+EQwP zGF(;D8BmhsEEy3*vSg5)LDIUwlJl}e0cU!W@r+$V{tCT;MFIlu0Hv!qW4zaRqfx5<~ zpo3`qO8>gz?L_=bB2DvE%d!2uoZIsKZRd05*VGD+?=D=j=S$to0dNtI;fMAQ!T(Cd z+LCp+H2F8MME({$z731fw?_#VSmzM#MGc>W6!fl%}!M-MsM<9iNQ zDF^J|A@_K1;*X1yhf}sfEXA7d@ok5z^g!(2pZBc?u?Uur5BRMbN)UC*{hYUa$z~bY z7I}Oh;wn*Qv7a2OUJLlBrt`GHqgt7AWEU<(P$~=7-^%MjMg)9?=P%<|#6pknM?7)Q zE24%++WQ;BP^?T8$x1xF8*$abK+WFLd>(vCaJ{L-BYq;X!6J|EL0q$w7Tv$OJjiR} z!kV{=-(7muLLR?Lu@^<~C_icQy=FL4m5X38UnGiOM)p)+QtUo5zig`5Cei;IMMKs6 zL+4=l2Z^wz5!T)GxD^jmRegPMZ+ zOoMSuns|-zO3WI+5}A9&F!L9ikm;1y2rr^ljbDT~{Qty;PS^7q;wA09S|~^JRWA?m zlKz+VoV4W~f>&0z#wRQ4F;uZ^10zGu{AC>b`T7p7;|6#wHNTD{S5K9f;zzpWqE}+S zUy1A0$17=4%zMhX6Pf$<$bV!3xP zj=1bsJ>vY`?<8N%8muz!ARM*IjNw2}ZICajv_x$2YU4OtYW(hyoPLEW7_a{~rHeKF zSo%C&oH`{{KY4lbki_p2_1&A@5zYtBan3>PV%F7u(q3xY)&tgb^K+~LXcWIL-X(Tz ztch{5{v*}NNu;TEPIUTT?P9r~pGpSv)^yCsI8|Jvef>W2xZG=Raw)H{Z!Q~(;o!8= zZ^kLI@j{R zxB$g~Z&)zQ8d6#j&Tsgcm7`;fh8C6W<4)GfnXDQj_YyPq`duPt{MIUh9uhtPRlc7{ z166D9e@NFdGA(Lyy$!+8eO;MM=jHfh7F5h%Hw6>9GVG{bv9@d~zIOga$NQc5su_6; zJwDm+!7QC_k)vz1(bw2?lz%2(n5yh3ty zwLA6f#fUlpuaI0-ZH0d?2f$}ACgK(TypcvOueMEL|Ngu`+WvVXja;_MNS9PO-}`rz z128u0?}^2fzSdSaE6S;Hgk{j*bEA-S~rEq#7H zuaNAh`AIu4uaI0)?REYudxhlUYRBT@DE~{ZkX%&#So~+_oWHtPNG{xKzxKX+uKx$l z6FINiO#HJaWiOM5{V*KJSrtZlS=Fy5pD`YQt0JpNKWjLUGpmhu2l_tu$IBsS^ZI`? z?LuDvHe__0KtQf0hoM5j4~DHR9oTvIT<=W*2*W``4+z!aMW(8H45X6)XK6NX4{+A1d9_!;Y|-D$DZr z{a(a^34gLDd{bq;*YwO{QsdwEM6I_FMH}95AuMPyy-6oMY%ZH=QL{55OF>gsErX%6 z$LkUohyM`=s8Fe+L^IS%0&&(zX~?vN4#A@)&-Z9m8}-{0pr#Q_u1v>O?*pZ9yYJdq-yZ?qZj{F z`L!RdmX`%_fZK4&R1on;-0MvJD5#%`uaI~lmWSsKS|Fw+7wJ~MW z>cEH;X>1)>m!*D`JuZ><@Q?x1yx z*RFy+>3=RO{iDmz1x6LVPZh9UM5>mK4FBfx7qQG6iYubZqWt}T4MTV-7}rH$|Hv9U z|2~wJRPp7OdqZ%vD&0K$bu=Ce{F5I!@xhfK&VAsV1~uU&-e4TA$~GkXIGmW52J_Pz zy|uO8ARG(LE&Ja`7q;(3`Y~#B(Br&;IOx`z98?8Y)aGz~$QG^r`TV$Hw{q3m6{X8z zamsrFS9nL^@HGl2^6Cg}K^Z6Cr+%)t0;3XV#pfBOqwYSln(i^X)N^{CS zJ`SE>qSMXtajOQQmLv=OL7eA7HW>E#qrrXVzFv3{HOYPZ^CI>bg+@oFdw(q@-jTRG zt8{qn<9vu=`k>WIqUOHrCa8W6a-7$*hM{p%*}AoxrXz0;E}g#&9@n^4D`(>N;X})o zmj<`_=A%5Z=yC!7Aup(uZYV{}M>{Ll(ujp*Weenp-}a8c$*y06>u-;Nh99KLd3v9R zSLA=@SQ(-QS;**EeINAfEy4JH9tQC1N*1D_%m1l(F&n)eI2RQ)cr7FS+!WMY(cz8L z5sY?J*_9_+)g7jOis6swHRt1tEnfE;#-ZTQe8k0bK1O}f&wLSjRK;IceX^V?#4SSb zUEsZ~yv8JN3CrvM4UCPNxiJ1|yfpSn>^S2)V`HkA(s2&}Es=p6aE9B~`4jd4_$!-a zKWg{3p0Wm+uj$Y0)9AnGNwhZEPE4)v3k`tnMtXY{za^NV;?DH*S79Sd6qKt>_EFBp zybJC7t5)tY0Te^Is6E;pE!1UvGcjIL5MM$Jj%97_jcWbA;P~^GuPP6F5cxOWKjIMn zfPdAACzZ&7?1nO2W#ToJ$REos1DEsON0deRZ^?q%>(v)wgI^XevdVjoj{Em`&(Y9w zK$Bkb=W18c?fV7&Dvk?zq1shi2XN${bwtQ>)fU{m!Z$dA9?IP$~0>zl2EM9atRi#cDTw*pK5? zyH0K&$&G*OcJJ>wV5fmR^;>t*x~@FcAsg&{>C~?>5BYzoyJ$bmE%J1=J2?OR8Q@vg z?`1#0Rb1reYK{H3DnyIH)?}d`bK2}tdTWTCVd7S(?v*&<(7IJ5e1J?e$7BTPNZ{vyFRqX_HU!Q{- z5C4*?6M2Hy|LYsiX{mgwUh=ABpTtAru79nZYn_hl3U-Knk=@+7(mLF{*6a{}AU-bk zm)JBs{kQ*qI|IL+f#1%+|FtuKE`r4{m8*0S1dasvMx`ehZW1CVB)m?%hnp7E#`*3G zFkDtH;!Q#oV$WjU%Iuqc$U69=aC8?gSqURrtXSLRErO?8Wl7)xy^19(SE5Xg6-x3R zB))%(mCjhJw0=n`7WT|X3k7!-9|65?YBqA;zxO5&71_ISb?I8Z21X7O zasj5tp_W15Cu2}iRYf~?({;S* zI18Qk_VFLsSrBhH$nE8eS8ed$s|tWJ&j+O+zC{A>oAqBHKO^hCX*eT$jf@BOvcdo* z3!1?R!N-IoEXO#@ys0>FmA<%rUlmnqO~H1Q!4`ge(I`s2ykP(#DXcUt^QQcYM^j;U zFGpM9O~%pCDO>$Gruvgw<-P4*sM}W^ZG$%n$E)FGUG;e3e$W`b>|e!@*Z&*Q@A>>c zkxVB(No+yI|3bHw^N2Hpy^TBny%c}mJlcHRo?;EQo-@|#KN=s!UW!eM&(gQy*M9r& zw=?kn$1_lU1vrRZ)lD1z2eU--akaN=IG|Ti(^(?6wtfN3Im zujUu`p6@pD3J!FdNZzfsnsR{W!9V9eH%%n(Y&6nqs+8h_gYILt_6w(pX20y{`!`J_Z&zP@{$H8WQK=6=pN`Q`2f`D^u^0Q)`-Kli#0%v&O_?Q`&OV5ca|;d^&d$SViXFZ^@o zV82W)@+zPIzoS2@rG7|#nff60M(TyskHPqf-M@ zN2EHWnx*Qb?BuTGx5eymAD{rS|Xd+m{^`THZdhJGSM&5HPJFrH(|N|aldfiabI#D zckgzucQ0|zbo1^ecez{QPI8C2z1)s&Q@6HjINv+}aNcsBhevU{bG37U^E)TwR5**B zS9F2X;K4h=4XV?Sm7Iry1mz~76u{CS~o6bhD{_HT;f*r(k`=9m) z_RCn=u+zTWKHJXO6?U0D)gERaVYjjC+A-@p>jUd$>rrc`b-8u6m9r|WGHa?e%sRqq zW7V}{=6B`?=F8@z=1%i++!rxtR+wexRCAblgxSWdYsTW=#XpF@9Dg*vGk$se?07C- z5ig5RjSq_-5pNT(8;`}li+vD#IreC5XYBIW*|A)#B32fg8XFcnBGx8WHx|Qsj1P>L zjYo~0#^uJ@M$V`(%8aSTFyjcLjZxQ#>EG!e=r8M!>O1wz^|SSyUZI!iQ}to`5qcZF zt{$V`(GTd$^ijH#UQW-ZIa)!>=u|q49zomCx-^ChAQdMR|LLTIcDi=H_S0^By7u1) z|4#dz_MZr!s-3F+7~zw(leHfre4=)u_U|ZHuGhYg@Cn)p+P@-P)QZ}75iVe9$v>lv z-~Tqkc|88jZk*S?j&M%PXN^ z-_zdH{t@AKw0E>mBK&vl@7l)^{+sqU?V||4t-Y;%7~!|Hx3mu;{HFG%_I`xl(B9DA zi}35(>)N{!eocE#dndxLYOiX4kMLi$ziNMr@GIIY+S`iTkrt$d_Ev`G zxG8C_y{`CB(h4^Mc`eGjKG$B2a1+u*`)h<7lg8RB5pF~pX)h~oOPY~p+DlQ!-+M8_ zP4W0&cH^en3lVNe8fwo+_z-f4_FRM;kOtbb5w4HzO#U38j((aFNf+V#2qKxC;8(|lZ-?JOL+T9Vhh^5^XVH1nP?u>Ap z#I-vjtP@?kJ;H<#ZD*8sRcg0ISRcl|@$a%ISN7Ka7~yBNXSGWs{AcaY+Kvc6qdlWt65*${r?rbC{FL^Tc2R_%)SlEX zRNPd16#p)Wa^-OC{0Kj!J*531!VhW>YUf4x0qp_p+z8*V-LIV!;rq1vwBJYgUhQ7( zY{gBqJMr(VC|6F_&W!MF+HKky5x!NsRXaVxw`jL$r$zW??Pl$F5xz;gNjo*dH)=O( zrzmbr+L3nJ$q_!39IBlZvUWB8ofzS(z$fg+S82rvU#?xH6%;qpuEf85gs;%9&~g#J z9Gu;aFV{T94Yh0WFB9d;`C2-{muZ)2$0tR8Cpu!`AGJSjkKm=+rQ0I7L))=6f|qEQ z92dchwTrj7_*3|f>g15+%QuIxWXYxw7BAiy!lFeRLRh$PeF$Y`6(KArTNi-^53Vf5%l z3hECWxG;qN1IrZD8*=o55C#mGA42~D^CHl{G=zTrkBvaT5(RZ<%$OU(^ciy^Fnx9i zQ>V@fVak-5AxxfpObC-E%?M%Q#OWbSm@qAb@#Ck4FmBwG5XO$39Kx6}lR_9hdSV2g zpAf>RQR726`si^X3?Du=gki(RgfMjI=n#e+Jt_i2jt*h);E^Hp?K>ibK7EFV(5u(5 z5RN=@Xb3%f4hi9iBL;`iz5AdL4nKTg2wgrt%8BEffbRnB(q%xXKklz|=g$3G#3)dOuvn|2BgCI=rH zfcB&IV_OBEX`i)G@RIgYYXy&MkGE2Aw{~|+1=nlWw@`4Qc42b`p5`@Ucs2Z^Sh=!k zAP+mNNgx|EXdK9T^%@1TZrz4TRvr?_gAQsC$Xd1P2Qrzg7sy1SZXg|}P9PaOIFPn| zP#`UkJI)1Z+}I9OrRA$c!t|Bv z#(l+D%vZK;_$p@Wfr`;M{vQ70^Z(cNJGI#2*o@f7Sg%;SSc8~l{M-20c-45qxYM}G zIM*l|8;!-r3}d9x%V=jbFf9Gw`p5dK`V;z{sjpJ+rd~=tmbx=_P3nTwsj1^rYf}s0 z7K}{wfmhHdlQL?5o&2SbgzG>^ArWzmFAS8}+O8KjiJDL;R2SAM#j;2x;We5Ptyv zDT+UkN22%{c{s#>Y5yV*h4`KJ9eFUse`^0E4}|!w_AR+T#ILolNjOF8pq*d2RokV8 ze7{*Z|G%(s!jJPS?JII`h+k@7l6yk@Li>W;9pdNO=j5&sKg0Lr&IsbsJEHg*xjjT! zUu0*9A8Q|z+d}+E`-t2cB77)vONg+Y$ju?bbs{%~2-At&7$Q6-azlu)n8@`ZzO21W zt_u+pP;zYqD~FP6LVQwtl3X1kh6m)T5FgVXBUgrqxhlB=!vg$AW7g!!W*j+R!H5y$4+@44 zC+8^`Hk_QRV8{@1j)K8M$nQfKOwJBKU%Hf>6~Yd3rh>Ai{Fp^O)Xb?#&7&wp|uVBCcvRy&{0c2YU{mE7Z{rZvP6!h&&_@fzU#C|*P6g$VP6l!kbfb~8CPidU18C|*V8h6v+?%n9)d;Or<~ zL1sm92bmcnOc8QSi10+nj1VscPLJZHWLgw=kf|Z=z-gZn#Y@TLDDEJWLWG|}CWd&i zb`_Zr#Y@QeC|*p)g?N#65g8jIWsj|?fN%&M*=y;s)sj|?q7~xZ8p$&uZsj|?zP7W7yEE}hTPn7vj4LpNSl%=jD zUHxYmqzldnCcUVX_2@U6bXL@T!E(|`4Z#lA`0%b{guerK*p0s8TY4c`chqP%Eu|nFok(eQE*g)bTtzS=KAyrfmBcyff zh#pdTIiVq~T}wzv_~L5nU0~jTf0bDOPZG2vum69Q`Y82w>gCiksYg<0XhQy%pPJp+12cP zb_&~$j)3`W3LD0HvO`$|X4^m7pW1KQf41+pZ?rG5Pq#C6xxD}#0mJPh?RNGdHnaX? zeP+F7J&U>jP1X+U49m0Dp(kLPHNxs;wYM5t4(9!zn{S)XnGc#bo0pnrnpt!OEHtN^ zBQfXiU^X({_)qaK;(v=jAAcx*OZ<=Vv(Oi?KE5bEBYt$ePrPHiaXb;*75g&w_t*=u zhhw+KE<uc*M93y#c>B3h-%`7&DF0Mn9vo z(bPy8n*O!^uKuF_Cv*o~p`W7{^-cOxeU?5(@2_{!o9VT5LcgK!(O2k`^j>-+y%Ze+ zMY;ukK`EU=N6dst-PFNNC0csvWynM z>eX&~J3zKK;F@>S+d{0|>ZZ3UMDv52J}!c%yXh?o(fZ(~H!DQvgPY!@5RDIRdZR-0 zJ-F!&Ay%$;)9V$Y>%mP|C`8kPn_j07Jr8cWJVgGxYZW3^x#=|#%)9B;3eoT2rdRnW z+8x~V$`C6%x#<-OF;jNa%N3&6!A&nyh*k$Ty;LDO9o+O1g=lne(~A|N&%sSEQiwJO zH@#3Hx*XhenL89r?#9Y%&mny_u(@h_%5d95qxD86oca+)Ynch_(hdJxw9H z8r<|$g=lJU(^GsDJq>Ppa)|s-O;U)C1~)x1f=%7@1cm5laMR-zqMgA_k5h41LH$B`((Z%4Vhbe@M z=%$B;SQ&TILlmNg!A%cVhz8AUI z$dB7sA^b@<-6w*so9?X;hNPSBr4WXsn?6z@3`sZLQz0BkH+_Uc47A;Jj}UiNy6Nr; z(X!yC4-b%cEV$`z3gJMy>BB2QvH{DzzW*u(2nL@NDxap<}VGg8++H(g&L+7jG!J%#8>aMN`aVlw2W>-Z>o65RB`Ay!Uy(+4R;M}nKK ztq=_fZn~C2%%0tJN+H@2+;mbQx)I!TkY$^o72!rV&9f{|UZNMlP4he}5v>SrnrB*p zyc5Aq^IR(tjR&)&L=%FW=2=%FdJx<+&$|I$ z?xuO>(i1#8lf2(y!<~aD(jIP($;98zkR`+74}p8|2=G8m7u_ zka`0=T@OhOBqkBLYI^hgOJD2g4D)gK=jOSCKXU}Fl znF>8?7UP*z=$W$^Poq-LWITroJ$*Fe2~_COqZ!YhT%*Z=@zg1F|ACC>O|H>mzgO3xd_s>J#dy{e`skw>PkBNQAI^Bb6MEP%#*>}ULx(b+>4e64 z<7rOnA&loZp$89UJi!Uww=d(_P3S&-7*B0N_v*!XUK9GrBNLig;+ct#WYh$Gmb zKzHxXcrFt^Km2gU6PeImKDLp?@E>m;u#v%}{MbhJ60&n=8<|VUjvehjzUkI~$ow$hK{5WGNxrw6T$)glyf~Ms^aiRVy2rNywHhZDb`OTePr|k%VmC z+(tGMvRN}5nMlZ{O>JZ$A)7R@k%5FnF&f!N$VQE9WF8?KHe|>;@S8-VfQ^&`w~6-& zFr*vtW3&k{r^rMhSo+xd;u>+HM&;L!@LQ8#6GYWiECVwkU(D>AIkg>~eaAk+y#8r+A8PtL*qJQHHn1gZ78}oopsL@IHA8!b ziMsxm_ItRe;nS$>-)diJpKt#TwfznDGP}f{jOu=0yQ|&Gu5UY5CF=VhS#Ma+S&vwE zSl3z?TW4AYYm2qYnvV+qXzM7e8!G&DEz|tL{M394GyR9mThaf2j(LK)1@-+BbD}u} zD*z5P4>29g^}me26MqqF0Pcui9X~&QN_=~KO?*C90gQNa;}hcz41xV(LwOs2Y7T;mESyIEZ5 zK29VP-7Kzl9|!Gv$j#!C_wgwHD6abe`J=e_ee6r{r|=CB_z^jr9PVcEMF?=$3vL$Q z2OqofN9DjLrhQ1plksj=4tzKcB7@wl9Qbe?NCvuDIq>0l6n`?l3*t|4Jj%_=fe(Ll z02$zB<-mtye~xnCBeB1mCF*y(lkRR-4t)5d-SHwlIr8Dy4Uc+q=p(V4>&dYX$HNFv z4t^va#ymdyAta`K(4qzN6A3!raB8NXQ?fvG>nJ33T9Pl(b00K0_BXR`9 zp&9em`G5NT#(;7k-fP;Fd21Em53C8GNfYL+4xlk$m4e2Ow-Rw5*T)jx7Up;>6jk3C%UVdaA3 zVQ?fLYfhRw9)?Gfnv$lDhXIl#Ox7I_LnKL2(co-;2!gSj4 zFjSI+*|g(fup|kSX~)BGNfMHp<6*!g=^%2D<6+1o36o>T!=OnLI&2&d!zM{+uyH&L zoFrj7>v$MCNy7Bi@ur02I^N`fFllzYNdaLh>v$6b!c5ljCIp0utmBOj2#qz4H!dK| zY8`KEK*;5eHzpu-)i~bhknq^3kTl1`kV?Ex-csXu7*t8Z1lI8|tdfNJtK(r{B?kt_f>0lEJU-|WgzAXn@o|?Rv|u%=76@i0=8gyELsVXP(z z4Hu4w(V8UmTi9M35W*DNEo=|tHK|{)J&f0c9zELjFkTaS6g0+bLJu2ddl;{AjZO>O z!+1?<{4U08LQkA%dl<0^Jq{XUHmS$i9!70KkAcRxP3RFLY!4$hp@$E*J&fIi9tMrk zo6tjs*dE4jLJx+<2u|w3wuel|HM%Qo4{1*79kz!YCv+Kpjsz!lne8FF2|XVgsZHwn zwuihX^t^ethomNSX{qfYqX|8Gw(TLENqw#DA(siw&jS*f)Hn~wVnUA{YkNpxLXUw) z{*ro(?IC#yJqj9`OKO~3q%EQOc}C8XdYJ7YVF^8GknJH`2|aM2ok6M+dcXiXgFGd4 ze`q8rsr%a*WGJEg^|LcbPeS+YYiE$1gznwj&LBAn-K&?AL2lw#is+zlGDuE7?Rv?{ zAUjDy|Ado4dh%)4V@?M7NfNpzoD33FK>eKzGL$6rPBM7$vlORGos6HQIN`7OS&G8b!P)h*6em2>&r+Q5Og~Fe?)Nwu zKTA>gJ$Mg(mg0oR{4B-kVkhHgDXO6@!pZnqisDA(7dja~OHmC?5q5@WDSW5V6Jcj~ zjuQGcmO+NX{7`g6*cl`!%u#tm1j`JU5Ac2nmO*yn&%&aldszm_NdUSbtPFA!reSD? zurhw!ObA2 zVDOAK3f}bKW{^~TM9+hpK~_=totr^g5hywy+zj$cfR%OJOk0J>+inJ##eWou+sz=k z_=s)?H-r4*BNDlrX{ivI+|9I5h*a)okZAlTBbU1w z5$z3b2Fb`rbT_yewar2412V6T8$DU$pq`CjrR zI0ttouR({vsmbG$Ym*C-(~={TeXvVF(_}KSEAd6*?ZmT*`x7@LE=ru5*p^tCn41`v z7?9|aXqrg6yRb9B+wQaO{q7C!MeeEYHg}~v*B$2$aJyhnfTXj_`NDY{I|4lH+~!>2 zoa>zE9OtZbj&&wEL(%u&4*LPPY!~~Ay~|!=kFh)1HS7X*Dm$L7Md$xCHiGqrN6?5l za0ouN-+(`Gk9{rNfs^bl@CIhtqwRinC%cKAw02ovTJKmdSPxk@i^_k-T5HX-CSh;H z!>yLqK^8T?G2gGU8^A1cJludI&5r2yKiD+m|A8OyUi`1|r*TJxTjN*45%^u)L&yKJ zcu9ORJb}LPuJKm!`tW`#;R<{ddn5K7`u^{TT?=2}OmzNliLKIa(l5iz{$%}leVx7- zbNdPUP`#Jl3A6h;x<&s>zry_fRr(BlkluzF{sr_jnxz{t$3GTz{gJdEX8EmY1L~s2 z-9;(^7c8K61$y2*dS{?ZOX(efKK598d!S27=*~dTol9>E^z7O6)eUJ!TBOBG98p)5`-rY81UJ&?86EKL&cl2zqIthYh1U0zGsny(G{> zhR}-xJ$Nv^DA0oj(F+4Ta3H-P(Ea<<^8?+lAN@n1`}U>h1-efkdTyY5_on9rx>qmy z`=I?VK`_H4uZQ^CDvtlFt2ly>@5m$R*@5oalb#jm?%nB`f$r9go)PH74x^_Bx@%W@ zTA;ghp}z}s=g#!hKzHgyPYHB~4)o+ew{K5R3Us@6^u$0PdMG_1&~4k&VxZf!p@l%V zZcX!nZq7I7+o1?U8gJjuF*JM?l;}V$WvqrnL?LEdDmlfX@n=^ z@g<6%Bu9`V=;A2vdVwyAa1T7bQ1KId6i<0OHt$@Fk&Cq4X1c)d00eH{Oy>tr*1pHm z_nYXv2;ZRHKuaTxnhHHO!q@GlVf2R4 zF%dpRJB5ypFq*~as0g2gcXD)u(H}-fMi>oZbVP*tJ0HFq=jpHr=d?T>x*O-{kO-qF zMh8bYqh;uz2=g;Ha5p}l9;MFY*W0$y0U>SOO8cub^))}Q{lW*gY@vNa+PsDKiO^=+ zJETpUX|D)vqDLzFYWWV@Q_+_zSJERQw1f5tX~hcKJ*4F;=;0AsPP>J)Y#BW)q@~Mf zS4Cee+ChU>@0j-ak|i`~_2#sLc2dv$eDPx1F{DL{X@>|cqU}RkxRACBsca!VRMBS` z$+V5o3$#s0E@54HEF5sFdd+X#I{z6q&G z6Y_OPjhm3KBGj0CiD1M}tE@^uv8~goznRqAhQsVK%or$Z_<9|}(ICS{WO^i@twZFDM#Qgqw?Dlszy8AD)&$Ca3<-XBgVb8Ot+hgoOc2B#b-5mS= zG4ufZ)B41E+j`M@!nzNu{I9ewuuiw~=mJ=cZvUCqcx$ND+v<6-O*c$INJJe#z!&RB%i0Ar1TMt7sFafspSmHIc>E$~(N zHuvi{!$CMlKT+SRuh!@5)AXbDe&`Npi9G`?`XBlQeTTk8AE$TI>**!*Oq!>g;2xCF zN!U5Cm!`2ij6b-caSiqx%ft2)0P~IIVfqPx^~UnB`~>iRo;(ac0iSB0vOMfQ0Vs^I zJj^};pJ<=3JghzeD3Y-}j6MNqR%dzGd;-v_&hjw%1fWr!5sj&+uYxhDYq>MRdyPXG#TEDvK(0J_mx9=4tU6yI1Lrk()Q-dG-%o&c2ISRRI+ z094*s9(JAp6y8`KW}X1l-B=!0o&es*l!uWg;6?34mWPce0NZ@AJWM4hCs>~Oz`LjNwhyT0vApF2 zig_$=`tT?1Pb?qz0b880e9VW3wTD^W@BvMhzB)CZJ7SZ;|AsBN&^Vjp-< zTW*mLsBEy@LLacEh2_e8_=ENbmRsNhmcg;yd>_u$&SklIKAf$c&2ptaz?){dV|_qz zgXKzmfDz4dbA7-JiRI?_fWo16gi@4_K*W<;DXD ziErO-<;F?5b*q&_5sClwxZ|uGDo9*z*<$5JOSyTol^Z4HrcGAvXel>tv~nY*+_1sQ zjgWHPIx9C^%JOn6H%!X4YpvW+Dc7vAazms4{F$MXf` z*s)fQXA8(NW2_v{6_BGxTREO7AV-a|ay(B!jvQ&_c$R=1F~Z8VkaE~CE5|bgemZoh zmE-vVa>x)X$Fl?E;K5do=LX0@gRC6S43Gl{S~;E}lnA9)RrL-OBMS0NJgZmE$=8 z5)G<3o&g|HK+N&@580)QmE+MLvU6uE$74Tar%qOmM}Ei-9jqLW`;hJ1TR9%}A=|aH zay;fkq6C@a5g)Q`TPw%oJtV4;IUemHQH{*;SP$8%m6hX>9l+E$jwa!8a+vpkYRrczdx$8kuM zOtU75y1rt56{UWhQnNv79PtY zi1TJZ?%=uEdGeXkz;m;u3X#m*ECM<-wsW(H?BYS;+PPW8b{}C~xmiSa z9}%nFEaJP5FrHWz5ngmY!m)F*i16aia>LHeBE*Yl3ct?DBFOWTBMi=3b^=Vk{hL~?VpgA^jYxmn}~|H(K@ZWcL0 z;qfesJRwdR%sG}trjW38tCK~p5T}J(b1aK=As^bZg=LX2By0vCW%#g}Wsx)_Yyu!{ z_^^p(kvIgvlw(<>4gv7wSQg2{2RwrGAz{S|mPG=QupEFC;=^*5MG}#)Y#GZUjYwDu zKq3(UGmd4EN(8`*W7$p$@Cee0d<4sXvPdWr76Fh_d|1S?NGcMr8YqjjBB2a`#3BGD z9LplL`0xVDBDqMws+BC#i-d82mOj9MW7!r8u4P#y8S%7FXU}o7NHgLi67HLuMXCvq zfB%tge1!MrW|49P3hT|yBJBjopN!PwBaAmUi}a(gr<+9z@)5S1n?)K@*u%{t75NC$ z&CMbm2}D0Pnab<`|IlC7QvXbSoO(0$JnHy&rmn-v|FclZKMw2u7o=vOmOl_KK)Y09 zRPzn2`Ts2WchvKrNZy;gDR~)ufRjYU}|osDn= zW@FF)fzIJhYp0%LVQ;_BaM!};;Rf8suE37}C$i(%N_H%p#D-#he>>KYx%MvmEBjsh zCHpb^PWu}B0yqH2+iS7Hf0{kg?t?miQ@fT;G57z_dewT$y4SkFx&&+dv(|cRF=qW^ ztO3~FuccMTiktub|DgB(1>6bXF5`M*2fF`HFt!?Na5sQsFvlN+`vJ5!ni#clM}Qyn z&-Hh3Pk<-&`!LJDTt81gMNi|t088{a`b69rpts&xZ-IHfO@E?a4o%k+#z5ST|rB6kAS0Tf7%Uq3Gl00=JMrwP#iaxEz^VIxVdzx9u&vTB}??6IBqUl zqzA=ubKycgD2|&8EA^l_ZkCnlL2=wHJ6{iqC^R~(rQkdrU#W)bIce$ zsI;1+N9#eQ)f_cS4=SzZsN40R(rOMGqz9E&vtK_wsI;2Bdg(!>)$G*_(J)pQ&^sI;0`{}fbOP1Dqa zN-LH(=|QE{Gz@)3@L6Ns)N;S-YU;~i3S+iS<76-ytS?mrV_sjP2)4YwSP|BL=!+D= zg4Y)+LcghArl>FJtMkIDJOVmQ_4$f=livC~MJT-JrHW8i(T`QsgY?i#6v4vR=PJUY z7k!Q**!B8sMKI^}S&F)lF8WMG*zsRKMiDlA(q|~@NIL4%6=A)GK21@3(q5mc2xhp> zYp3%4!3)ys4Wjt}}oMR30L35sy{27SCD7~T3fMew=xv5K%`f<8tOOmBU( zBADFzC`H)gKo2&wio@@&ua8uZ!SL3D9j)SUyX)$~j#hC}ht$!79j)Sgqf>pT`n_7D zmOexg)@0~|6=6+=K1dPPWatAGxy03ZT~wYWtisTFT~rcQVd(u8VHJkX%cAlztisTF zSyU31;_19BDhaDFbY2#fgpr%h%c7Fdy{hxFs3dGGp!2e*Ail4F&dZ{bunI%xWl>33 zg`s!%xBZH1KUM0yDk>lPLHj}HRZ&UUh)U;GQAt=#q4TP!Bs68}yecXQJ()VMib_ID zrp~LPl3)qzyecXQp0LiVqLN?=>%1x|39hittD=%%3+ucpDha-@&a0x5U<~WLDk=%i zu+FQZl3)$%yecXQ-muQAqLN?^>%1x|3GT4oToLSHy_q8T!+KLiFo^Xgir^6IjTONn z)*C5;N31tg1d~`lL=jwKy@4Xw#Cm;2@QL+$ieMD$brr!W*6S#ORjePZ2wt&%kRq7H zdTm8;i}hNHU>EBtMevLDq#_u`ShgCR7dXZ`-@{6tC0NF~qX?d{&J@8k)@?;_jde>A zY-8P21m9SXD}r&X#}vUi)(u6lj*( zMKF-*Pm16mQ(n!KzXuDM@@l3ec*vAjGbO=9ro5Ud2`)0_)l5meUxrsRCBa9gyqYPA z_s;NYrX=1u!>gH+U?o#t&6ET$neu9;B$&yRS2HESO{Tn>DG7Em{ZbM9Wcr077|Qf> zMR1hqXNq=cJLsp1;3?C8D1xa>KT!l%nSQJYwle)l5qxF(p&}T|^aDk3mg)P7U@g=4 z6v11j?<#`1Oy5xicbWcO5$t99H$}Vy=WRtWnCV-Jctg#big-KC8;W={&FhMIE6r<) zcq7fLig+8%Uls9oqE{5bXr?bKg40Z2QpDS3UR1=JW&WawHyyp82yQcdK4=2lzMVc7 zXf(+@8)!5l{W;L+k9j80=+1gN(3>{VrvklkBYiT^8#d4<0=;e>eLT?R<@B*YuU$(Y z4fL8d^iP3ay_!A}X!OuL9BA~+JQV2V%jtuGMu*G;fnK_l-XCc6yxbS)MT_XYfkubR zJ%KJOqjv}Tw|-*oBT`t?|M&WddHuf)ovWq3O?{Yp4SWARkh%qZ{O6@kPHo3d{$-f` zkH#JV-BPVn^;0bQQ}QeH^1qUN5_A6>lb0sXP8P8bz)I})Hzhd&-Ta+#&%cAQ55NzJ z&k}!2{3Y=ydH}9PNB`;A=Wjz|X<|-dLSjgwXQDmk{iy^+AHc`%8|dqQ6f^%D-OI2C zz^Uj3*yyfs=eg6}G1%v?CwBI4?$&h~b^`dP^9j2AUv!?pPJg#JSK`ipr^5x<;;hDg z05hHO==JZ7-hfuv!sF=l|dA7vT!rWnX7sj9ve8=nGh8&$TDoL$UK;2lW5fvUTfU=m~fe`vN?S z`vG2seF4t0PQcFp(2+s~>g-XlpgHYT?d+KVtX)_s!SLXU#{^7sYu({U$&E%DX3 zW8lpA`1nxl7SIJN0~#Q$(%AR0&tmVyUWq*&dl2^yyav4iXT?s4ZHtv-m%ur(NwHzL zn?uJ~(^zdR`uX1Yhw-*?px(iDeU0c47^(Ngu7NGE!oZ|I($DGN>5JGw;4XR{y_lW> z-(VwMhD#~(A1iW)mf-SVyZTI46b3DaRjXN%`?KPaRjXK$yR#hDz~3u!b5;N>y&|_} z1z<~`A~$9Qz{M+aTUG!z^(k^wR=|PFEip1_K5VDT8I zj8lXID<3LoCpbkoumKgEA}m-*NI7;99xVQ|>2b%|MOd&>pJW%|zzV%(i(P~PEA;f~ zb`k!o)KA$(*snrQn`Rf`z6w2cs$GQnD)f{ob`jpI&{%0&g!L*kR@oNeyb3*Gf?b61 zD)jjAb`id-(BsD0McA%FV>N0KuB*^v#@I!eu0ms_ZxNoW(4$7#MOdyvAAPi4gySmo z$dPsthO5vcM%YF8twLk9ZV`5?(8Gq=MYyd(4;^Y3VYUj5mAgfFtwIkTY!_j*3XPSl zML4ZOW3QwlY*q}PY;q&H(JsPd#mYXwi>wHTRXmU*IaY+fDqsScz>08JIpE%KKd~ab zRRO#`q6lYI0JaZfMfj=$c;7=2uBrgu8c>9%DgdJot5^qs4{_Tzt9Y=K6%|(TASqGl zE!LJ2mD6G^DNztCrljoA!zw1FL~XE`kn-@ut)eR>%7jHnN|XtUOiI)Si?)=g4Hhk5 zR$4_qyEGSh~WZm zIO-@?KoIu|6a`Xaj^qq#}3M?pbGbLfk$ z0-`xS1{2*Y1q5?0(Y;bYEa#GczYxl~qcC6j--%k!ws z0fs-z^SI3cem~3e$jt$EKg;vj%>iyd%k${X0cJnT^Z3mHUO&t82+jdkKg;tN&H+w8 z%kwDC0Y*Q|^El1{K0nLzNX`K^Kg;u2&H*kz%kyZ?0VY4o^LWky9zVo-^Z3pI-agCo2+sl5 zKFjkM&jHRp%kwDD0meSdkMjY(KFjk+&mV!U&+&Jl=DFr_b^{ z;&Xtd&+@;0^sAbJR-jU*!V1u$S(jcKFcHW3xJ8w@`(Hb z;Ni18BEJAw_$-geF8~fc%Omm&z#x_75%~qczh`+weh%EeXL&?^0dVhG9+6)F2Bmf$ zkssq&?%lII0>5|w);-H3@C$%*&+-WT0$|*;d>0?!+p|1Ezjy?;J;GTq zZ)>qkY<+BLY%b=F!()A7U1BX`^u?etc5ipDK@Y(hZr|D|Rlz9i z6L7fO)@|q}(MRxa=QHQ;&P#9+?nNiTW!Np?WaoHiowFFd1QXyP^l~~m&7C^vCipLQ z40s=_3!Z_4a2va7w><+!vOcU6_7$wn40s3s!2JTBw;!=@x35M=!S8U-fC_stb`BVa z6$L%)L#w%G;2+@}HJ}y20?m*Lc5_b;xBKCId+1UND8)6s5PSu}9xBm_L zMf$1wHhm@T6*vz20Cv%v>Pfl_eg1FLXX*X)26_=am2Sh%|8ubm-~if%HZ`vF*8|yP z6aS7HCq)=vQ{%)4*8`uh8`m?65w1(>8HL@ru91&$9a7iG?Z$PCY=rR@H@pbrTW(|` zj4!&8jxfIK#_j z#>NQi;0?R6ZmbVktHl1F6^b|V>8!CX%9XlN9$^FzV{ORzea%=CWv$Xs#q$*_^k9dB z-k8t<6{w<;p=uu+ZEl>v0?UM~q;hIcL=0 zj9{TTXEb;k!9sJ+sDc^6LUYcjgBgn=e;<`FBUot8AHNA)rXH`rZQSoO7DTynx)H23 zw@C%xLCFZ#nsdGjTpD>kYI(-75xxl=tTpG)NA1lB)|zuhz0C;LnsY|Q%?Q?-bG}Zy z&Is0;bH-L%#!S>{FiV0rw646|I3_}88Z$y#Q*KOGRK9ktF)c!88dF1Bv(}grp*03? zl8{FzU$w@Vq-gEx)yBjKooVnU3HeO8p$2b~2xyhTnfp`ginhX8@x%vr$vhl z-X!7E!i5HJlJKdl%-~HDJ}p>a@FoeL=Fc~HlY~$6<{867DlIj5lZ5|R>6Hd=lJKdd z#NbU5KFytL@FoeL=FBm8lY~#RXB+zRPPMA4X;2hI0Z|Z@3@R!# z86_i1P)TA$k_t$aW1=eu^{ALI=NwQm=bVo@pvN3h4Cqn%d*)huty(?a``&Ty8Q=Ks z``+>DKl9hSyE;&{pPKWTb1rIob71Wv3Z%{fCa!h% zuOUjLPXFj&Gpdm~`_&-l?5knL3TGb;lP5d{`!}#$|cMaplIo&jj9qa6^Vayn3FAbwdJ9Ln?{63>bIbAgze6T|& zY0E1I9punS+7d>NbUJGoF~XsfwB?oI!yP(FTf(qmPA3gRhdOkUw!AWAh(jl7OBg)Z zp_8;F3>xInN!k(`8XVOQATDPJhbW{vtyH{tvC~q;^X57A zDxNykX`$kN{hT@#_vz!LRouI`lTvZ7UQSZQJ$pJ_#XWjB2^Dwi=7cKVdv7Puag*b# zxNBF(Q*pa?j;rFGc5;}C+qQKa71!50wu)OAo80~XgRg9Lo-=dbe!2UJ*DC(^CFFMZJKdX4j z5_^k|F-xlA1q1&_BT4d!v0#tX!QI_ z#c0<2QpY#gU#J+3o1d$A+&KF)9p7Pps^YO@?SHCx#0dKn9p7(%tm5Ir?M*5kIMDt` z$B)?`s(8Qv`vVp4vyc70j-RvNQ*pn3_PZ+X-rasj$FJFMtGHV?`z;ltq4P~0zia

G34?~)kJku) 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 N`| dict[str, Any]: """Return a copy of payload with sensitive values masked for logs.""" diff --git a/src/helper/map.py b/src/helper/map.py index b4bd8ea..3b38c9d 100644 --- a/src/helper/map.py +++ b/src/helper/map.py @@ -10,6 +10,11 @@ class Map(dict): dict (dict): dictionary to map. """ - __getattr__ = dict.get + def __getattr__(self, key): + try: + return self[key] + except KeyError: + raise AttributeError(f"Map has no key {key!r}") from None + __setattr__ = dict.__setitem__ __delattr__ = dict.__delitem__ diff --git a/src/session/polling.py b/src/session/polling.py index 27341fc..abe5859 100644 --- a/src/session/polling.py +++ b/src/session/polling.py @@ -144,7 +144,17 @@ async def get_devices(self, _n_id: str): # pylint: disable=too-many-locals,too- api_call_start = time.monotonic() try: api_resp_d = await self.api.get_all() + api_call_duration = time.monotonic() - api_call_start + if api_call_duration > self._slow_poll_threshold: + _LOGGER.debug( + "get_devices - Hive API response took %.1fs — marking poll as slow.", + api_call_duration, + ) + self._last_poll_slow = True + else: + self._last_poll_slow = False except HiveAuthError: + self._last_poll_slow = False _LOGGER.warning( "Auth error (401/403) after token refresh, " "falling back to full device re-login." @@ -154,15 +164,6 @@ async def get_devices(self, _n_id: str): # pylint: disable=too-many-locals,too- self.api.get_all, reraise_as=HiveReauthRequired, ) - api_call_duration = time.monotonic() - api_call_start - if api_call_duration > self._slow_poll_threshold: - _LOGGER.debug( - "get_devices - Hive API response took %.1fs — marking poll as slow.", - api_call_duration, - ) - self._last_poll_slow = True - else: - self._last_poll_slow = False if not str(api_resp_d["original"]).startswith("2"): raise HTTPException if api_resp_d["parsed"] is None: diff --git a/tests/unit/test_device_registration.py b/tests/unit/test_device_registration.py index 372eada..43ad3bc 100644 --- a/tests/unit/test_device_registration.py +++ b/tests/unit/test_device_registration.py @@ -482,32 +482,9 @@ async def test_other_client_error_does_not_raise(self): result = await stub.forget_device("acc-token", "dev-key") assert result is None - async def test_endpoint_error_does_not_raise_api_error(self): - """EndpointConnectionError only raises HiveApiError if class name is - 'ResourceNotFoundException', which can never be true for an - EndpointConnectionError. The exception is therefore silently swallowed.""" + async def test_endpoint_error_raises_api_error(self): stub = await _make_stub() stub.loop.run_in_executor.side_effect = _endpoint_error() - # The guard condition is always False for a real EndpointConnectionError, - # so no exception propagates. - result = await stub.forget_device("acc-token", "dev-key") - assert result is None - - async def test_endpoint_error_named_resource_not_found_raises_api_error(self): - """A subclass of EndpointConnectionError named 'ResourceNotFoundException' - satisfies the guard at line 339 and raises HiveApiError (line 340).""" - stub = await _make_stub() - # Craft a class whose __class__.__name__ == "ResourceNotFoundException" - # but which IS an EndpointConnectionError (so it's caught by the except clause) - resource_cls = type( - "ResourceNotFoundException", - (botocore.exceptions.EndpointConnectionError,), - {}, - ) - resource_err = resource_cls( - endpoint_url="https://cognito.eu-west-1.amazonaws.com" - ) - stub.loop.run_in_executor.side_effect = resource_err with pytest.raises(HiveApiError): await stub.forget_device("acc-token", "dev-key") @@ -636,33 +613,6 @@ async def test_other_client_error_is_swallowed(self): result = await stub.confirm_device("name") assert result is None # no HiveInvalid2FACode raised - async def test_endpoint_error_wrong_name_is_swallowed(self): - """EndpointConnectionError subclass with wrong __name__ is swallowed (190->193).""" - stub = await _make_stub() - stub.generate_hash_device = AsyncMock( - return_value={"PasswordVerifier": "pv", "Salt": "s"} - ) - wrong_cls = type( - "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} - ) - wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") - stub.loop.run_in_executor.side_effect = wrong_err - result = await stub.confirm_device("name") - assert result is None # no HiveApiError raised - - -class TestUpdateDeviceStatusSwallowedEndpointError: - async def test_endpoint_error_wrong_name_is_swallowed(self): - """EndpointConnectionError with wrong name is caught but not re-raised (211->214).""" - stub = await _make_stub() - wrong_cls = type( - "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} - ) - wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") - stub.loop.run_in_executor.side_effect = wrong_err - result = await stub.update_device_status() - assert result is None # no HiveApiError raised - class TestDeviceRegistration: async def test_calls_confirm_and_update(self): diff --git a/tests/unit/test_hive_async_api.py b/tests/unit/test_hive_async_api.py index 86c1cd7..ade420c 100644 --- a/tests/unit/test_hive_async_api.py +++ b/tests/unit/test_hive_async_api.py @@ -65,50 +65,42 @@ def _make_api_no_token(_url_contains_sso=False): class TestHiveApiAsyncRequest: - @pytest.mark.asyncio async def test_successful_200_returns_response(self): api = _make_api(status=200, json_data={"ok": True}) resp = await api.request("get", "https://beekeeper.hivehome.com/1.0/nodes/all") assert resp.status == 200 - @pytest.mark.asyncio async def test_201_also_succeeds(self): api = _make_api(status=201) resp = await api.request("post", "https://beekeeper.hivehome.com/1.0/nodes/x/y") assert resp.status == 201 - @pytest.mark.asyncio async def test_sso_url_without_token_does_not_raise(self): api = _make_api_no_token() # Should not raise NoApiToken because "sso" is in the URL resp = await api.request("get", "https://sso.hivehome.com/") assert resp.status == 200 - @pytest.mark.asyncio async def test_non_sso_without_token_raises_no_api_token(self): api = _make_api_no_token() with pytest.raises(NoApiToken): await api.request("get", "https://beekeeper.hivehome.com/1.0/nodes/all") - @pytest.mark.asyncio async def test_401_raises_hive_auth_error(self): api = _make_api(status=401) with pytest.raises(HiveAuthError): await api.request("get", "https://beekeeper.hivehome.com/1.0/nodes/all") - @pytest.mark.asyncio async def test_403_raises_hive_auth_error(self): api = _make_api(status=403) with pytest.raises(HiveAuthError): await api.request("get", "https://beekeeper.hivehome.com/1.0/nodes/all") - @pytest.mark.asyncio async def test_500_raises_hive_api_error(self): api = _make_api(status=500) with pytest.raises(HiveApiError): await api.request("get", "https://beekeeper.hivehome.com/1.0/nodes/all") - @pytest.mark.asyncio async def test_404_raises_hive_api_error(self): api = _make_api(status=404) with pytest.raises(HiveApiError): @@ -121,7 +113,6 @@ async def test_404_raises_hive_api_error(self): class TestGetAll: - @pytest.mark.asyncio async def test_successful_get_all_returns_parsed_json(self): payload = {"products": [], "devices": []} api = _make_api(status=200, json_data=payload) @@ -129,21 +120,18 @@ async def test_successful_get_all_returns_parsed_json(self): assert result["original"] == 200 assert result["parsed"] == payload - @pytest.mark.asyncio async def test_timeout_error_propagates(self): api = _make_api(status=200) api.websession.request.side_effect = asyncio.TimeoutError with pytest.raises(asyncio.TimeoutError): await api.get_all() - @pytest.mark.asyncio async def test_os_error_calls_error_method(self): api = _make_api(status=200) api.websession.request.side_effect = OSError("network down") with pytest.raises(web_exceptions.HTTPError): await api.get_all() - @pytest.mark.asyncio async def test_runtime_error_calls_error_method(self): api = _make_api(status=200) api.websession.request.side_effect = RuntimeError("boom") @@ -157,7 +145,6 @@ async def test_runtime_error_calls_error_method(self): class TestGetEndpoints: - @pytest.mark.asyncio async def test_get_devices_returns_parsed_json(self): payload = [{"id": "dev1"}] api = _make_api(status=200, json_data=payload) @@ -165,7 +152,6 @@ async def test_get_devices_returns_parsed_json(self): assert result["original"] == 200 assert result["parsed"] == payload - @pytest.mark.asyncio async def test_get_products_returns_parsed_json(self): payload = [{"id": "prod1"}] api = _make_api(status=200, json_data=payload) @@ -173,7 +159,6 @@ async def test_get_products_returns_parsed_json(self): assert result["original"] == 200 assert result["parsed"] == payload - @pytest.mark.asyncio async def test_get_actions_returns_parsed_json(self): payload = [{"id": "act1"}] api = _make_api(status=200, json_data=payload) @@ -181,21 +166,18 @@ async def test_get_actions_returns_parsed_json(self): assert result["original"] == 200 assert result["parsed"] == payload - @pytest.mark.asyncio async def test_get_devices_os_error_raises_http_error(self): api = _make_api(status=200) api.websession.request.side_effect = OSError with pytest.raises(web_exceptions.HTTPError): await api.get_devices() - @pytest.mark.asyncio async def test_get_products_os_error_raises_http_error(self): api = _make_api(status=200) api.websession.request.side_effect = OSError with pytest.raises(web_exceptions.HTTPError): await api.get_products() - @pytest.mark.asyncio async def test_get_actions_os_error_raises_http_error(self): api = _make_api(status=200) api.websession.request.side_effect = OSError @@ -209,13 +191,11 @@ async def test_get_actions_os_error_raises_http_error(self): class TestSetState: - @pytest.mark.asyncio async def test_file_in_use_returns_file_response(self): api = _make_api(status=200, file_mode=True) result = await api.set_state("heating", "node-1", mode="MANUAL") assert result == {"original": "file"} - @pytest.mark.asyncio async def test_successful_set_state(self): payload = {"id": "node-1", "mode": "MANUAL"} api = _make_api(status=200, json_data=payload) @@ -223,14 +203,12 @@ async def test_successful_set_state(self): assert result["original"] == 200 assert result["parsed"] == payload - @pytest.mark.asyncio async def test_os_error_calls_error_method(self): api = _make_api(status=200) api.websession.request.side_effect = OSError("fail") with pytest.raises(web_exceptions.HTTPError): await api.set_state("heating", "node-1", mode="MANUAL") - @pytest.mark.asyncio async def test_runtime_error_calls_error_method(self): api = _make_api(status=200) api.websession.request.side_effect = RuntimeError("fail") @@ -244,19 +222,24 @@ async def test_runtime_error_calls_error_method(self): class TestSetAction: - @pytest.mark.asyncio async def test_file_in_use_returns_file_response(self): api = _make_api(status=200, file_mode=True) result = await api.set_action("action-1", '{"status": "on"}') assert result == {"original": "file"} - @pytest.mark.asyncio - async def test_successful_set_action_returns_json_return(self): - api = _make_api(status=200) + async def test_successful_set_action_returns_status_200(self): + payload = {"id": "action-1", "status": "on"} + api = _make_api(status=200, json_data=payload) result = await api.set_action("action-1", '{"status": "on"}') - assert result == api.json_return + assert result["original"] == 200 + assert result["parsed"] == payload + + async def test_runtime_error_calls_error_method(self): + api = _make_api(status=200) + api.websession.request.side_effect = RuntimeError("fail") + with pytest.raises(web_exceptions.HTTPError): + await api.set_action("action-1", "{}") - @pytest.mark.asyncio async def test_os_error_calls_error_method(self): api = _make_api(status=200) api.websession.request.side_effect = OSError @@ -264,13 +247,59 @@ async def test_os_error_calls_error_method(self): await api.set_action("action-1", "{}") +# --------------------------------------------------------------------------- +# Tests: HiveApiAsync.motion_sensor +# --------------------------------------------------------------------------- + + +class TestMotionSensor: + async def test_url_does_not_double_base_url(self): + payload = [{"timestamp": 12345}] + api = _make_api(status=200, json_data=payload) + captured = {} + original_request = api.request + + async def capture_request(method, url, **kwargs): + captured["url"] = url + return await original_request(method, url, **kwargs) + + api.request = capture_request + sensor = {"type": "motionsensor", "id": "ms-001"} + await api.motion_sensor(sensor, 1000000, 2000000) + url = captured["url"] + assert url.startswith(api.base_url + "/products/") + assert "motionsensor/ms-001" in url + assert url.count("https://beekeeper") == 1 + + async def test_motion_sensor_returns_parsed_json(self): + payload = [{"timestamp": 12345}] + api = _make_api(status=200, json_data=payload) + sensor = {"type": "motionsensor", "id": "ms-001"} + result = await api.motion_sensor(sensor, 1000000, 2000000) + assert result["original"] == 200 + assert result["parsed"] == payload + + +# --------------------------------------------------------------------------- +# Tests: HiveApiAsync.refresh_tokens +# --------------------------------------------------------------------------- + + +class TestRefreshTokens: + async def test_no_name_error_when_session_is_none(self): + websession = _make_mock_websession(status=200) + api = HiveApiAsync(hive_session=None, websession=websession) + api.request = AsyncMock() + result = await api.refresh_tokens() + assert result == api.json_return + + # --------------------------------------------------------------------------- # Tests: HiveApiAsync.error # --------------------------------------------------------------------------- class TestError: - @pytest.mark.asyncio async def test_error_raises_http_error(self): api = _make_api() with pytest.raises(web_exceptions.HTTPError): @@ -283,13 +312,11 @@ async def test_error_raises_http_error(self): class TestIsFileBeingUsed: - @pytest.mark.asyncio async def test_file_mode_raises_file_in_use(self): api = _make_api(file_mode=True) with pytest.raises(FileInUse): await api.is_file_being_used() - @pytest.mark.asyncio async def test_not_file_mode_does_not_raise(self): api = _make_api(file_mode=False) await api.is_file_being_used() # Should not raise diff --git a/tests/unit/test_hive_auth_async.py b/tests/unit/test_hive_auth_async.py index fb541f8..fa6939b 100644 --- a/tests/unit/test_hive_auth_async.py +++ b/tests/unit/test_hive_auth_async.py @@ -79,12 +79,27 @@ async def _make_auth( class TestHiveAuthAsyncInit: - def test_pool_region_raises_value_error(self): + def test_pool_region_no_longer_accepted(self): from apyhiveapi.api.hive_auth_async import HiveAuthAsync - with pytest.raises(ValueError, match="pool_region"): + with pytest.raises(TypeError): HiveAuthAsync(username="u", password="p", pool_region="eu-west-1") + async def test_async_init_sets_running_loop(self): + from apyhiveapi.api.hive_auth_async import HiveAuthAsync + + auth = HiveAuthAsync(username="u@test.com", password="pass") + assert auth.loop is None # not set until async_init + mock_data = { + "UPID": "eu-west-1_Test", + "CLIID": "client-id", + "REGION": "eu-west-1_Test", + } + with patch.object(auth.api, "get_login_info", return_value=mock_data): + with patch("boto3.client", return_value=MagicMock()): + await auth.async_init() + assert auth.loop is not None + async def test_file_flag_set_for_magic_username(self): from apyhiveapi.api.hive_auth_async import HiveAuthAsync @@ -444,6 +459,14 @@ async def test_new_device_metadata_in_sms_stores_keys(self): assert auth.device_group_key == "sms-grp" assert auth.device_key == "sms-dev" + @pytest.mark.asyncio + async def test_no_authentication_result_key_does_not_raise(self): + auth = await _make_auth() + auth.loop.run_in_executor.return_value = {"ChallengeName": "SMS_MFA"} + result = await auth.sms_2fa("123456", {"Session": "sess-1"}) + assert auth.access_token is None + assert result == {"ChallengeName": "SMS_MFA"} + # --------------------------------------------------------------------------- # Tests: refresh_token diff --git a/tests/unit/test_hive_auth_async_extended.py b/tests/unit/test_hive_auth_async_extended.py index 54d408d..b74a02b 100644 --- a/tests/unit/test_hive_auth_async_extended.py +++ b/tests/unit/test_hive_auth_async_extended.py @@ -90,13 +90,13 @@ async def test_async_init_sets_pool_id_and_client_id(self): auth.client = None # trigger async_init flow mock_boto_client = MagicMock() - - auth.loop = MagicMock() - auth.loop.run_in_executor = AsyncMock( + mock_loop = MagicMock() + mock_loop.run_in_executor = AsyncMock( side_effect=[_LOGIN_INFO, mock_boto_client] ) - await auth.async_init() + with patch("asyncio.get_running_loop", return_value=mock_loop): + await auth.async_init() assert auth._pool_id == "eu-west-1_TestPool" assert auth._client_id == "test-client-id" @@ -116,12 +116,13 @@ async def test_async_init_splits_region_correctly(self): "REGION": "ap-southeast-2_XyzPool", } mock_boto_client = MagicMock() - auth.loop = MagicMock() - auth.loop.run_in_executor = AsyncMock( + mock_loop = MagicMock() + mock_loop.run_in_executor = AsyncMock( side_effect=[login_info, mock_boto_client] ) - await auth.async_init() + with patch("asyncio.get_running_loop", return_value=mock_loop): + await auth.async_init() assert auth._region == "ap-southeast-2" @@ -712,10 +713,10 @@ async def test_other_client_error_in_initiate_auth_falls_through(self): class TestLoginInitiateAuthSwallowedEndpointError: - """Arc 284->288: EndpointConnectionError caught but class name is wrong.""" + """EndpointConnectionError in initiate_auth always raises HiveApiError.""" - async def test_wrong_name_endpoint_error_in_initiate_auth_falls_through(self): - """EndpointConnectionError with wrong name is swallowed; response stays None.""" + async def test_wrong_name_endpoint_error_in_initiate_auth_raises_api_error(self): + """Any EndpointConnectionError subclass in initiate_auth raises HiveApiError.""" auth = await _make_auth() wrong_cls = type( @@ -724,7 +725,7 @@ async def test_wrong_name_endpoint_error_in_initiate_auth_falls_through(self): wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) - with pytest.raises((TypeError, KeyError)): + with pytest.raises(HiveApiError): await auth.login() @@ -771,10 +772,10 @@ async def test_other_client_error_in_challenge_falls_through(self): class TestLoginChallengeSwallowedEndpointError: - """Arc 313->319: EndpointConnectionError caught with wrong class name in challenge.""" + """EndpointConnectionError in respond_to_auth_challenge always raises HiveApiError.""" - async def test_wrong_name_endpoint_error_in_challenge_falls_through(self): - """EndpointConnectionError with wrong name is swallowed; result stays None.""" + async def test_wrong_name_endpoint_error_in_challenge_raises_api_error(self): + """Any EndpointConnectionError subclass in SRP challenge raises HiveApiError.""" auth = await _make_auth() challenge_response = { @@ -797,7 +798,7 @@ async def test_wrong_name_endpoint_error_in_challenge_falls_through(self): auth.loop.run_in_executor = AsyncMock( side_effect=[challenge_response, wrong_err] ) - with pytest.raises((TypeError, AttributeError)): + with pytest.raises(HiveApiError): await auth.login() @@ -884,11 +885,10 @@ async def test_device_login_calls_second_respond_to_auth_challenge(self): class TestDeviceLoginEndpointWrongName: - """Line 389: EndpointConnectionError with wrong __class__.__name__ raises - HiveInvalidDeviceAuthentication instead of HiveApiError.""" + """Any EndpointConnectionError in device_login always raises HiveApiError.""" - async def test_wrong_name_endpoint_error_raises_invalid_device_auth(self): - """A subclass of EndpointConnectionError with a different name hits line 389.""" + async def test_wrong_name_endpoint_error_raises_api_error(self): + """Any EndpointConnectionError subclass in device_login raises HiveApiError.""" auth = await _make_auth(device_key="dk-err", device_group_key="grp-err") auth.device_password = "dev-pass-err" @@ -898,7 +898,7 @@ async def test_wrong_name_endpoint_error_raises_invalid_device_auth(self): wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) - with pytest.raises(HiveInvalidDeviceAuthentication): + with pytest.raises(HiveApiError): await auth.device_login() @@ -930,10 +930,10 @@ async def test_other_client_error_is_swallowed_returns_none(self): class TestSms2faSwallowedEndpointError: - """Arc 431->435: EndpointConnectionError caught with wrong class name in sms_2fa.""" + """Any EndpointConnectionError in sms_2fa raises HiveApiError.""" - async def test_wrong_name_endpoint_error_is_swallowed(self): - """EndpointConnectionError subclass with wrong name is swallowed; returns None.""" + async def test_wrong_name_endpoint_error_raises_api_error(self): + """Any EndpointConnectionError subclass in sms_2fa raises HiveApiError.""" auth = await _make_auth() wrong_cls = type( @@ -942,8 +942,8 @@ async def test_wrong_name_endpoint_error_is_swallowed(self): wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) - result = await auth.sms_2fa("654321", {"Session": "sess-abc"}) - assert result is None + with pytest.raises(HiveApiError): + await auth.sms_2fa("654321", {"Session": "sess-abc"}) # --------------------------------------------------------------------------- @@ -952,10 +952,10 @@ async def test_wrong_name_endpoint_error_is_swallowed(self): class TestRefreshTokenSwallowedEndpointError: - """Arc 479->485: EndpointConnectionError caught with wrong class name in refresh_token.""" + """Any EndpointConnectionError in refresh_token raises HiveApiError.""" - async def test_wrong_name_endpoint_error_is_swallowed_returns_none(self): - """EndpointConnectionError subclass with wrong name is swallowed; result=None returned.""" + async def test_wrong_name_endpoint_error_raises_api_error(self): + """Any EndpointConnectionError subclass in refresh_token raises HiveApiError.""" auth = await _make_auth() wrong_cls = type( @@ -964,6 +964,5 @@ async def test_wrong_name_endpoint_error_is_swallowed_returns_none(self): wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) - # result initialised to None; exception swallowed; line 485 reached; returns None - result = await auth.refresh_token("some-refresh-token") - assert result is None + with pytest.raises(HiveApiError): + await auth.refresh_token("some-refresh-token") diff --git a/tests/unit/test_hive_exceptions.py b/tests/unit/test_hive_exceptions.py new file mode 100644 index 0000000..33ffb5c --- /dev/null +++ b/tests/unit/test_hive_exceptions.py @@ -0,0 +1,93 @@ +"""Unit tests for the hive_exceptions hierarchy.""" + +import pytest +from apyhiveapi.helper.hive_exceptions import ( + FileInUse, + HiveApiError, + HiveAuthCredentialError, + HiveAuthError, + HiveConfigurationError, + HiveError, + HiveFailedToRefreshTokens, + HiveInvalid2FACode, + HiveInvalidDeviceAuthentication, + HiveInvalidPassword, + HiveInvalidUsername, + HiveReauthRequired, + HiveRefreshTokenExpired, + HiveUnknownConfiguration, + NoApiToken, +) + + +class TestHiveErrorBase: + def test_hive_api_error_is_hive_error(self): + assert issubclass(HiveApiError, HiveError) + + def test_hive_auth_error_is_hive_api_error(self): + assert issubclass(HiveAuthError, HiveApiError) + + def test_hive_auth_error_is_hive_error(self): + assert issubclass(HiveAuthError, HiveError) + + def test_hive_refresh_token_expired_is_hive_api_error(self): + assert issubclass(HiveRefreshTokenExpired, HiveApiError) + + def test_hive_failed_to_refresh_is_hive_api_error(self): + assert issubclass(HiveFailedToRefreshTokens, HiveApiError) + + def test_hive_reauth_required_is_hive_error(self): + assert issubclass(HiveReauthRequired, HiveError) + + +class TestCredentialErrors: + def test_invalid_username_is_hive_auth_credential_error(self): + assert issubclass(HiveInvalidUsername, HiveAuthCredentialError) + + def test_invalid_password_is_hive_auth_credential_error(self): + assert issubclass(HiveInvalidPassword, HiveAuthCredentialError) + + def test_invalid_2fa_is_hive_auth_credential_error(self): + assert issubclass(HiveInvalid2FACode, HiveAuthCredentialError) + + def test_auth_credential_error_is_hive_error(self): + assert issubclass(HiveAuthCredentialError, HiveError) + + +class TestConfigurationErrors: + def test_unknown_config_is_hive_configuration_error(self): + assert issubclass(HiveUnknownConfiguration, HiveConfigurationError) + + def test_invalid_device_auth_is_hive_configuration_error(self): + assert issubclass(HiveInvalidDeviceAuthentication, HiveConfigurationError) + + def test_configuration_error_is_hive_error(self): + assert issubclass(HiveConfigurationError, HiveError) + + +class TestStandaloneExceptions: + def test_file_in_use_is_not_hive_error(self): + assert not issubclass(FileInUse, HiveError) + + def test_no_api_token_is_not_hive_error(self): + assert not issubclass(NoApiToken, HiveError) + + def test_file_in_use_is_exception(self): + assert issubclass(FileInUse, Exception) + + def test_no_api_token_is_exception(self): + assert issubclass(NoApiToken, Exception) + + +class TestInstantiable: + def test_hive_error_is_raiseable(self): + with pytest.raises(HiveError): + raise HiveError("test") + + def test_hive_api_error_caught_as_hive_error(self): + with pytest.raises(HiveError): + raise HiveApiError("test") + + def test_invalid_username_caught_as_hive_error(self): + with pytest.raises(HiveError): + raise HiveInvalidUsername("test") diff --git a/tests/unit/test_hive_helper_extended.py b/tests/unit/test_hive_helper_extended.py index 8c13ec3..671318e 100644 --- a/tests/unit/test_hive_helper_extended.py +++ b/tests/unit/test_hive_helper_extended.py @@ -59,38 +59,6 @@ def test_returns_false_when_cache_is_empty(self): assert helper.get_device_from_id("any-id") is False -# --------------------------------------------------------------------------- -# get_heat_on_demand_device — lines 315-317 -# --------------------------------------------------------------------------- - - -class TestGetHeatOnDemandDevice: - """Covers HiveHelper.get_heat_on_demand_device (lines 315-317).""" - - def test_returns_linked_thermostat(self): - """Looks up TRV by HiveID, then fetches linked thermostat by zone.""" - trv_id = "trv-001" - thermostat_id = "zone-001" - - trv_data = {"state": {"zone": thermostat_id}, "type": "trvcontrol"} - thermostat_data = {"id": thermostat_id, "type": "heating"} - - products = { - trv_id: trv_data, - thermostat_id: thermostat_data, - } - helper = _make_helper(products=products) - - # Device accessed with dict-style key "HiveID" as used inside the method - device = MagicMock() - device.__getitem__ = MagicMock( - side_effect=lambda k: trv_id if k == "HiveID" else None - ) - - result = helper.get_heat_on_demand_device(device) - assert result == thermostat_data - - # --------------------------------------------------------------------------- # sanitize_payload — list masking (line 329) and non-str/dict/list fallthrough # --------------------------------------------------------------------------- diff --git a/tests/unit/test_map.py b/tests/unit/test_map.py index f6209f7..0cd5350 100644 --- a/tests/unit/test_map.py +++ b/tests/unit/test_map.py @@ -1,5 +1,6 @@ """Unit tests for Map — dot-notation dict wrapper.""" +import pytest from apyhiveapi.helper.map import Map @@ -15,10 +16,18 @@ def test_dict_read(): assert m["key"] == "value" -def test_missing_key_returns_none_not_keyerror(): - """Test that missing keys return None instead of raising KeyError.""" +def test_missing_key_raises_attribute_error(): + """Missing attribute access raises AttributeError.""" m = Map({}) - assert m.missing is None + with pytest.raises(AttributeError): + _ = m.missing + + +def test_missing_bracket_key_raises_key_error(): + """Missing bracket access raises KeyError (standard dict behaviour).""" + m = Map({}) + with pytest.raises(KeyError): + _ = m["missing"] def test_nested_access(): diff --git a/tests/unit/test_polling.py b/tests/unit/test_polling.py index e518941..2a18501 100644 --- a/tests/unit/test_polling.py +++ b/tests/unit/test_polling.py @@ -207,3 +207,77 @@ async def test_poll_devices_propagates_false(self): p.get_devices = AsyncMock(return_value=False) result = await p._poll_devices() assert result is False + + +# --------------------------------------------------------------------------- +# TestGetDevicesSlowPoll +# --------------------------------------------------------------------------- + + +class TestGetDevicesSlowPoll: + async def test_auth_error_sets_last_poll_slow_false(self): + from unittest.mock import AsyncMock, MagicMock + + from apyhiveapi.helper.hive_exceptions import HiveAuthError + + p = _make_polling() + p.api = MagicMock() + p.api.get_all = AsyncMock(side_effect=HiveAuthError()) + p.config = MagicMock() + p.config.file = False + p.tokens = MagicMock() + p._last_poll_slow = True # pre-set to True to confirm it gets cleared + + retry_result = { + "original": 200, + "parsed": {"products": [], "devices": [], "actions": []}, + } + + async def fake_retry_login(): + pass + + async def fake_retry_with_backoff(_fn, _reraise_as=None): + return retry_result + + p._retry_login = fake_retry_login + p._retry_with_backoff = fake_retry_with_backoff + p.hive_refresh_tokens = AsyncMock() + p.data = MagicMock() + p.data.products = {} + p.data.devices = {} + p.data.actions = {} + p.config.last_update = MagicMock() + p.config.scan_interval = MagicMock() + + await p.get_devices("No_ID") + assert p._last_poll_slow is False + + async def test_slow_api_call_sets_last_poll_slow_true(self): + from unittest.mock import AsyncMock, MagicMock + + p = _make_polling() + p._slow_poll_threshold = 0 # any call will be "slow" + p.api = MagicMock() + + slow_result = { + "original": 200, + "parsed": {"products": [], "devices": [], "actions": []}, + } + + async def slow_get_all(): + return slow_result + + p.api.get_all = slow_get_all + p.config = MagicMock() + p.config.file = False + p.tokens = MagicMock() + p.hive_refresh_tokens = AsyncMock() + p.data = MagicMock() + p.data.products = {} + p.data.devices = {} + p.data.actions = {} + p.config.last_update = MagicMock() + p.config.scan_interval = MagicMock() + + await p.get_devices("No_ID") + assert p._last_poll_slow is True diff --git a/tests/unit/test_remaining_branches.py b/tests/unit/test_remaining_branches.py index cb5b8fb..2b652d8 100644 --- a/tests/unit/test_remaining_branches.py +++ b/tests/unit/test_remaining_branches.py @@ -525,7 +525,8 @@ class TestSensorGetSensorHiveTypesSensorPath: async def test_contactsensor_in_hive_types_sensor_takes_else_branch(self): """contactsensor is in HIVE_TYPES['Sensor'] and not in sensor_commands key set, so the elif branch is taken.""" - from apyhiveapi.helper.const import HIVE_TYPES, sensor_commands + from apyhiveapi.devices.sensor import sensor_commands + from apyhiveapi.helper.const import HIVE_TYPES # 'contactsensor' is in HIVE_TYPES['Sensor'] and NOT a key in sensor_commands assert "contactsensor" in HIVE_TYPES["Sensor"] @@ -546,7 +547,8 @@ async def test_contactsensor_in_hive_types_sensor_takes_else_branch(self): async def test_motionsensor_in_hive_types_sensor_sets_status(self): """motionsensor is in HIVE_TYPES['Sensor'] and not in sensor_commands key set.""" - from apyhiveapi.helper.const import HIVE_TYPES, sensor_commands + from apyhiveapi.devices.sensor import sensor_commands + from apyhiveapi.helper.const import HIVE_TYPES assert "motionsensor" in HIVE_TYPES["Sensor"] assert "motionsensor" not in sensor_commands From fc7d0fb2d3fb693965637adf3145fbb8d5c28521 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Sat, 23 May 2026 21:38:12 +0100 Subject: [PATCH 02/29] fix: use device_id (not hive_id) to look up data.devices in sensor HIVE_TYPES branch Co-Authored-By: Claude Sonnet 4.6 --- src/devices/sensor.py | 2 +- tests/unit/test_sensor_extended.py | 35 +++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/devices/sensor.py b/src/devices/sensor.py index 42d815e..bed1ebb 100644 --- a/src/devices/sensor.py +++ b/src/devices/sensor.py @@ -157,7 +157,7 @@ async def get_sensor(self, device: Device): device.device_data = props device.parent_device = data.get("parent", None) elif device.hive_type in HIVE_TYPES["Sensor"]: - data = self.session.data.devices.get(device.hive_id, {}) + data = self.session.data.devices.get(device.device_id, {}) device.status = {"state": await self.get_state(device)} props = data.get("props") or {} props["online"] = online diff --git a/tests/unit/test_sensor_extended.py b/tests/unit/test_sensor_extended.py index 68122f3..604c785 100644 --- a/tests/unit/test_sensor_extended.py +++ b/tests/unit/test_sensor_extended.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from apyhiveapi.devices.sensor import Sensor from apyhiveapi.helper.hivedataclasses import Device, SessionConfig @@ -146,6 +146,39 @@ async def test_contact_sensor_in_hive_types_sets_status(self): assert "state" in result.status session.attr.state_attributes.assert_awaited_once() + async def test_contact_sensor_uses_device_id_not_hive_id_for_props(self): + """HIVE_TYPES['Sensor'] branch must look up data.devices by device_id. + + Before the fix, line 160 used hive_id; data was always {} so + device.parent_device was always None even when the device existed. + """ + hive_id = "prod-abc" + device_id = "dev-xyz" # deliberately different from hive_id + + products = {} # contactsensor is NOT in products + devices = { + device_id: { + "props": {"online": True, "signal": -70}, + "parent": "hub-parent-id", + } + } + session = _make_session(products=products, devices=devices) + session.attr.online_offline = AsyncMock(return_value=True) + + device = _make_device( + hive_id=hive_id, device_id=device_id, hive_type="contactsensor" + ) + device.device_data = {"online": True} + + sensor = Sensor(session) + with patch.object(sensor, "get_state", new=AsyncMock(return_value="CLOSED")): + result = await sensor.get_sensor(device) + + assert result is not None + assert device.parent_device == "hub-parent-id", ( + "parent_device must come from data.devices[device_id], not hive_id lookup" + ) + class TestGetState: """Tests for HiveSensor.get_state covering the motionsensor branch (lines 37-42).""" From 82db9cf1648e56681d23819d896cd609df53d2a1 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Sat, 23 May 2026 21:41:33 +0100 Subject: [PATCH 03/29] fix: guard against None from .get() before .split() in async_init and get_password_authentication_key Import HiveUnknownConfiguration and raise it instead of letting AttributeError propagate when REGION or UPID are absent from the SSO login info response, and when _pool_id is None or missing an underscore in get_password_authentication_key. Co-Authored-By: Claude Sonnet 4.6 --- .secrets.baseline | 10 ++-- src/api/hive_auth_async.py | 10 +++- tests/unit/test_hive_auth_async_extended.py | 53 +++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 58ab3f9..1c3de01 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -268,28 +268,28 @@ "filename": "src/api/hive_auth_async.py", "hashed_secret": "5dc786e32e3a0a4611daaf397721c6ef64cd71b0", "is_verified": false, - "line_number": 48 + "line_number": 49 }, { "type": "Secret Keyword", "filename": "src/api/hive_auth_async.py", "hashed_secret": "ac9f290e69cee683ba3c63461f1f3fa02765032a", "is_verified": false, - "line_number": 49 + "line_number": 50 }, { "type": "Secret Keyword", "filename": "src/api/hive_auth_async.py", "hashed_secret": "351b174ccf89601f6f4bd3f3970a4aba7d17c98e", "is_verified": false, - "line_number": 52 + "line_number": 53 }, { "type": "Secret Keyword", "filename": "src/api/hive_auth_async.py", "hashed_secret": "576956b5291ac38d04ef5f82cc974286a857f0b2", "is_verified": false, - "line_number": 104 + "line_number": 110 } ], "src/api/srp_crypto.py": [ @@ -580,5 +580,5 @@ } ] }, - "generated_at": "2026-05-23T15:37:16Z" + "generated_at": "2026-05-23T20:41:24Z" } diff --git a/src/api/hive_auth_async.py b/src/api/hive_auth_async.py index 8bd3875..420c47c 100644 --- a/src/api/hive_auth_async.py +++ b/src/api/hive_auth_async.py @@ -23,6 +23,7 @@ HiveInvalidPassword, HiveInvalidUsername, HiveRefreshTokenExpired, + HiveUnknownConfiguration, ) from .device_registration import DeviceRegistrationMixin from .hive_api import HiveApi @@ -92,7 +93,12 @@ async def async_init(self): self.data = await self.loop.run_in_executor(None, self.api.get_login_info) self._pool_id = self.data.get("UPID") self._client_id = self.data.get("CLIID") - self._region = self.data.get("REGION").split("_")[0] + region_raw = self.data.get("REGION") + if not self._pool_id or not region_raw: + raise HiveUnknownConfiguration( + "SSO login page did not return required pool/region data" + ) + self._region = region_raw.split("_")[0] # Cognito USER_SRP_AUTH does not use IAM credentials — boto3 requires non-None values. self.client = await self.loop.run_in_executor( None, @@ -151,6 +157,8 @@ def get_password_authentication_key(self, username, password, server_b_value, sa u_value = calculate_u(self.large_a_value, server_b_value) if u_value == 0: raise ValueError("U cannot be zero.") + if not self._pool_id or "_" not in self._pool_id: + raise HiveUnknownConfiguration(f"Invalid pool ID format: {self._pool_id!r}") pool_id = self._pool_id.split("_")[1] username_password = f"{pool_id}{username}:{password}" username_password_hash = hash_sha256(username_password.encode("utf-8")) diff --git a/tests/unit/test_hive_auth_async_extended.py b/tests/unit/test_hive_auth_async_extended.py index b74a02b..bc03922 100644 --- a/tests/unit/test_hive_auth_async_extended.py +++ b/tests/unit/test_hive_auth_async_extended.py @@ -966,3 +966,56 @@ async def test_wrong_name_endpoint_error_raises_api_error(self): with pytest.raises(HiveApiError): await auth.refresh_token("some-refresh-token") + + +# --------------------------------------------------------------------------- +# Tests: async_init() — missing REGION or UPID keys +# --------------------------------------------------------------------------- + + +class TestAsyncInitMissingKeys: + """async_init must raise HiveUnknownConfiguration when login info keys are absent.""" + + async def test_async_init_missing_region_raises_configuration_error(self): + """If REGION is absent from login info, raise HiveUnknownConfiguration.""" + from apyhiveapi.api.hive_auth_async import HiveAuthAsync + from apyhiveapi.helper.hive_exceptions import HiveUnknownConfiguration + + auth = HiveAuthAsync(username="user@test.com", password="pass") + bad_login_info = {"UPID": "eu-west-1_TestPool", "CLIID": "test-client-id"} + mock_loop = MagicMock() + mock_loop.run_in_executor = AsyncMock(side_effect=[bad_login_info]) + with patch("asyncio.get_running_loop", return_value=mock_loop): + with pytest.raises(HiveUnknownConfiguration): + await auth.async_init() + + async def test_async_init_missing_upid_raises_configuration_error(self): + """If UPID is absent from login info, raise HiveUnknownConfiguration.""" + from apyhiveapi.api.hive_auth_async import HiveAuthAsync + from apyhiveapi.helper.hive_exceptions import HiveUnknownConfiguration + + auth = HiveAuthAsync(username="user@test.com", password="pass") + bad_login_info = {"CLIID": "test-client-id", "REGION": "eu-west-1_TestPool"} + mock_loop = MagicMock() + mock_loop.run_in_executor = AsyncMock(side_effect=[bad_login_info]) + with patch("asyncio.get_running_loop", return_value=mock_loop): + with pytest.raises(HiveUnknownConfiguration): + await auth.async_init() + + +# --------------------------------------------------------------------------- +# Tests: get_password_authentication_key() — None _pool_id +# --------------------------------------------------------------------------- + + +class TestGetPasswordAuthKeyNonePoolId: + """get_password_authentication_key must not crash with AttributeError when _pool_id is None.""" + + async def test_none_pool_id_raises_configuration_error(self): + """If _pool_id is None, raise HiveUnknownConfiguration (not AttributeError).""" + from apyhiveapi.helper.hive_exceptions import HiveUnknownConfiguration + + auth = await _make_auth() + auth._pool_id = None + with pytest.raises(HiveUnknownConfiguration): + auth.get_password_authentication_key("user", "pass", "DEADBEEF", "ABCDEF") From 27d4a31ce0250b18c637e9c2776ee5c4268c0b98 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Sat, 23 May 2026 22:26:37 +0100 Subject: [PATCH 04/29] fix: catch ZeroDivisionError in colour-temperature conversion methods Extend the except clause in get_min_color_temp, get_max_color_temp, and get_color_temp from KeyError-only to (KeyError, ZeroDivisionError) so that a zero colourTemperature value returned by the Hive API returns None instead of raising an unhandled ZeroDivisionError. Tests added for all three cases. Co-Authored-By: Claude Sonnet 4.6 --- src/devices/color.py | 6 ++-- tests/unit/test_color_extended.py | 46 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/devices/color.py b/src/devices/color.py index ade4cb2..2442ae9 100644 --- a/src/devices/color.py +++ b/src/devices/color.py @@ -33,7 +33,7 @@ async def get_min_color_temp(self, device: Device): data = self.session.data.products[device.hive_id] state = data["props"]["colourTemperature"]["max"] return round((1 / state) * 1000000) - except KeyError as e: + except (KeyError, ZeroDivisionError) as e: _LOGGER.error(e) return None @@ -50,7 +50,7 @@ async def get_max_color_temp(self, device: Device): data = self.session.data.products[device.hive_id] state = data["props"]["colourTemperature"]["min"] return round((1 / state) * 1000000) - except KeyError as e: + except (KeyError, ZeroDivisionError) as e: _LOGGER.error(e) return None @@ -67,7 +67,7 @@ async def get_color_temp(self, device: Device): data = self.session.data.products[device.hive_id] state = data["state"]["colourTemperature"] return round((1 / state) * 1000000) - except KeyError as e: + except (KeyError, ZeroDivisionError) as e: _LOGGER.error(e) return None diff --git a/tests/unit/test_color_extended.py b/tests/unit/test_color_extended.py index 6795724..b605e0e 100644 --- a/tests/unit/test_color_extended.py +++ b/tests/unit/test_color_extended.py @@ -99,3 +99,49 @@ async def test_keyerror_on_missing_product_returns_none(self): result = await handler.get_max_color_temp(device) assert result is None + + +class TestZeroDivisionGuards: + """Colour-temperature methods must return None instead of raising ZeroDivisionError.""" + + async def test_get_min_color_temp_zero_returns_none(self): + """min colourTemperature == 0 must return None, not raise ZeroDivisionError. + + get_min_color_temp reads colourTemperature['max'] and divides by it, + so 'max' must be 0 to trigger ZeroDivisionError. + """ + session = _make_session( + products={ + "light-1": {"props": {"colourTemperature": {"max": 0, "min": 153}}} + } + ) + h = _make_handler(session) + device = _make_device() + result = await h.get_min_color_temp(device) + assert result is None + + async def test_get_max_color_temp_zero_returns_none(self): + """max colourTemperature == 0 must return None, not raise ZeroDivisionError. + + get_max_color_temp reads colourTemperature['min'] and divides by it, + so 'min' must be 0 to trigger ZeroDivisionError. + """ + session = _make_session( + products={ + "light-1": {"props": {"colourTemperature": {"max": 500, "min": 0}}} + } + ) + h = _make_handler(session) + device = _make_device() + result = await h.get_max_color_temp(device) + assert result is None + + async def test_get_color_temp_zero_returns_none(self): + """state colourTemperature == 0 must return None, not raise ZeroDivisionError.""" + session = _make_session( + products={"light-1": {"state": {"colourTemperature": 0}}} + ) + h = _make_handler(session) + device = _make_device() + result = await h.get_color_temp(device) + assert result is None From 487bc10d05db510f3b871fbaf7210537435d97b3 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Sat, 23 May 2026 22:28:31 +0100 Subject: [PATCH 05/29] fix: epoch_time to_epoch now honours the pattern argument instead of hardcoding it Removes the line that overwrote the caller-supplied `pattern` with a hardcoded Hive format string, so custom format strings are respected. Adds TestEpochTimePattern tests to confirm the fix and prevent regression. Co-Authored-By: Claude Sonnet 4.6 --- src/helper/hive_helper.py | 1 - tests/unit/test_helpers.py | 6 +----- tests/unit/test_hive_helper_extended.py | 26 +++++++++++++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/helper/hive_helper.py b/src/helper/hive_helper.py index 35955c9..150a027 100644 --- a/src/helper/hive_helper.py +++ b/src/helper/hive_helper.py @@ -25,7 +25,6 @@ def epoch_time(date_time: Any, pattern: str, action: str) -> Any: Converted value, or ``None`` if *action* is unrecognised. """ if action == "to_epoch": - pattern = "%d.%m.%Y %H:%M:%S" return int(time.mktime(time.strptime(str(date_time), pattern))) if action == "from_epoch": return datetime.datetime.fromtimestamp(int(date_time)).strftime(pattern) diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index 1f84c3e..9f9578f 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -38,11 +38,7 @@ class TestEpochTime: """Tests for the top-level epoch_time() helper function.""" def test_to_epoch_returns_int(self): - """to_epoch converts a date string to an integer Unix timestamp. - - Note: epoch_time ignores the *pattern* argument for "to_epoch" — - it always applies "%d.%m.%Y %H:%M:%S" internally. - """ + """to_epoch converts a date string to an integer Unix timestamp.""" result = epoch_time("01.01.2024 12:00:00", "%d.%m.%Y %H:%M:%S", "to_epoch") assert isinstance(result, int) diff --git a/tests/unit/test_hive_helper_extended.py b/tests/unit/test_hive_helper_extended.py index 671318e..f215a8d 100644 --- a/tests/unit/test_hive_helper_extended.py +++ b/tests/unit/test_hive_helper_extended.py @@ -109,3 +109,29 @@ def test_long_string_partially_masked(self): payload = {"password": "supersecretpassword"} result = helper.sanitize_payload(payload) assert result["password"] == "supe...word" + + +# --------------------------------------------------------------------------- +# epoch_time — to_epoch must honour the pattern argument +# --------------------------------------------------------------------------- + + +class TestEpochTimePattern: + """epoch_time to_epoch must honour the pattern argument.""" + + def test_to_epoch_uses_caller_pattern(self): + """Passing a custom pattern must parse the date string with that pattern.""" + from apyhiveapi.helper.hive_helper import epoch_time + + # ISO date — only parses if the custom pattern is respected + result = epoch_time("2024-06-15", "%Y-%m-%d", "to_epoch") + assert isinstance(result, int), "Expected int epoch timestamp" + assert result > 0 + + def test_to_epoch_standard_hive_format_still_works(self): + """The standard Hive date+time format must still parse correctly.""" + from apyhiveapi.helper.hive_helper import epoch_time + + result = epoch_time("15.06.2024 12:00:00", "%d.%m.%Y %H:%M:%S", "to_epoch") + assert isinstance(result, int) + assert result > 0 From d47ee022532f97a6fe8a1f832c2f01aaaf3660f1 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Sun, 24 May 2026 18:32:28 +0100 Subject: [PATCH 06/29] fix: remove SSL verify=False and urllib3 warning suppression; use json.dumps in set_state - Replace manual string-concatenation JSON building in set_state with json.dumps(kwargs) to prevent JSON injection when kwarg values contain double-quotes or backslashes - Remove requests.get(verify=False) SSL bypass from get_login_info - Remove urllib3 import and disable_warnings call that suppressed the SSL warning - Update TestGetLoginInfo assertion to match new call signature (no verify=False) Co-Authored-By: Claude Sonnet 4.6 --- src/api/hive_async_api.py | 13 ++------ tests/unit/test_hive_async_api_extended.py | 37 +++++++++++++++++++++- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/api/hive_async_api.py b/src/api/hive_async_api.py index 391c507..9223da4 100644 --- a/src/api/hive_async_api.py +++ b/src/api/hive_async_api.py @@ -6,7 +6,6 @@ import time import requests -import urllib3 from aiohttp import ClientResponse, ClientSession, ClientTimeout, web_exceptions from pyquery import PyQuery @@ -15,8 +14,6 @@ _LOGGER = logging.getLogger(__name__) -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - class HiveApiAsync: """Hive API Code.""" @@ -111,7 +108,7 @@ def get_login_info(self): """Get login properties to make the login request.""" url = "https://sso.hivehome.com/" - data = requests.get(url=url, verify=False, timeout=self.timeout) + data = requests.get(url=url, timeout=self.timeout) html = PyQuery(data.content) json_data = json.loads( '{"' @@ -251,13 +248,7 @@ async def set_state(self, n_type, n_id, **kwargs): """Set the state of a Device.""" _LOGGER.debug("set_state - Setting state for %s/%s: %s", n_type, n_id, kwargs) json_return = {} - jsc = ( - "{" - + ",".join( - ('"' + str(i) + '": "' + str(t) + '" ' for i, t in kwargs.items()) - ) - + "}" - ) + jsc = json.dumps(kwargs) url = self.urls["nodes"].format(n_type, n_id) try: diff --git a/tests/unit/test_hive_async_api_extended.py b/tests/unit/test_hive_async_api_extended.py index 5a9848c..4de0e13 100644 --- a/tests/unit/test_hive_async_api_extended.py +++ b/tests/unit/test_hive_async_api_extended.py @@ -111,7 +111,7 @@ def test_makes_request_to_sso_url(self): api.get_login_info() mock_get.assert_called_once_with( - url="https://sso.hivehome.com/", verify=False, timeout=api.timeout + url="https://sso.hivehome.com/", timeout=api.timeout ) def test_uses_first_script_tag(self): @@ -425,3 +425,38 @@ async def test_session_none_skips_token_data_read(self): await api.refresh_tokens() except (NameError, UnboundLocalError, AttributeError): pass # expected — tokens was never defined since session is None + + +# --------------------------------------------------------------------------- +# Tests: set_state() JSON encoding — Fix A +# --------------------------------------------------------------------------- + + +class TestSetStateJsonEncoding: + """set_state must produce valid JSON even when kwarg values contain special characters.""" + + async def test_set_state_escapes_quotes_in_value(self): + """A value containing double-quotes must produce valid, parseable JSON.""" + import json # noqa: PLC0415 + + session = MagicMock() + session.tokens.token_data = {"token": "tok"} + session.config.file = False + api = HiveApiAsync(hive_session=session) + api.urls = {"nodes": "https://beekeeper.hivehome.com/1.0/nodes/{}/{}"} + + captured = {} + + async def fake_request(_method, _url, **kwargs): + captured["data"] = kwargs.get("data") + resp = MagicMock() + resp.status = 200 + resp.json = AsyncMock(return_value={}) + return resp + + with patch.object(api, "request", side_effect=fake_request): + with patch.object(api, "is_file_being_used", new=AsyncMock()): + await api.set_state("heating", "node-1", mode='MANUAL"injected') + + parsed = json.loads(captured["data"]) + assert parsed["mode"] == 'MANUAL"injected' From b7b6494ca0c990ad6892e31955d460fc05ffcaa0 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Sun, 24 May 2026 18:39:08 +0100 Subject: [PATCH 07/29] chore: remove unused POOL, deprecated refresh_tokens and its tests, and dead url/status guard Co-Authored-By: Claude Sonnet 4.6 --- .secrets.baseline | 34 ++--- src/api/hive_async_api.py | 42 ++----- src/api/srp_crypto.py | 2 - tests/unit/test_hive_api.py | 113 ----------------- tests/unit/test_hive_async_api.py | 14 --- tests/unit/test_hive_async_api_extended.py | 139 --------------------- 6 files changed, 24 insertions(+), 320 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 1c3de01..28b888f 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -298,112 +298,112 @@ "filename": "src/api/srp_crypto.py", "hashed_secret": "3e619ee0820ecf213c2f38c634e416b53defe3b0", "is_verified": false, - "line_number": 11 + "line_number": 10 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "b8e0d506d969f09a9af89ce89fd9759b72c63262", "is_verified": false, - "line_number": 12 + "line_number": 11 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "e97a751edc71e9afbe0c0f63ec94873392833f9f", "is_verified": false, - "line_number": 13 + "line_number": 12 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "92488c021dd524a2f4e116666b3645308fa0e35c", "is_verified": false, - "line_number": 14 + "line_number": 13 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "d4571e2f026f458aecd2950b0eb6aec190276177", "is_verified": false, - "line_number": 15 + "line_number": 14 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "8109d3c2f659f13cb61fc9e71eed574efe8c8fd8", "is_verified": false, - "line_number": 16 + "line_number": 15 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "08cac7461d7b624b88c53ee47da09cbbb84ea290", "is_verified": false, - "line_number": 17 + "line_number": 16 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "95523fea7e6136c6148299dcc3077debfa2976b3", "is_verified": false, - "line_number": 18 + "line_number": 17 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "c978fb77621e86f5e9077653fe5345ac1616b466", "is_verified": false, - "line_number": 19 + "line_number": 18 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "fc02990268ecf8a35a4912d60dab3754e5f43846", "is_verified": false, - "line_number": 20 + "line_number": 19 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "2c2c0ca491a73e95c8965b6641731057b65f6462", "is_verified": false, - "line_number": 21 + "line_number": 20 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "672b25c6be065170206f3fc6346ebb8e84cbb9d3", "is_verified": false, - "line_number": 22 + "line_number": 21 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "99d02e268ea3ee849fb6e359c6c1b019e4d07efd", "is_verified": false, - "line_number": 23 + "line_number": 22 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "e677fc4cb09d99e1e0d30af31f2e209e541e380e", "is_verified": false, - "line_number": 24 + "line_number": 23 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "05b69b06f40cae0c910a15b1ac75b1f7a847eccb", "is_verified": false, - "line_number": 25 + "line_number": 24 }, { "type": "Hex High Entropy String", "filename": "src/api/srp_crypto.py", "hashed_secret": "c7f914bac2d66eb3f8ae3888fa47bf1ada6caaf5", "is_verified": false, - "line_number": 26 + "line_number": 25 } ], "tests/unit/test_device_registration.py": [ @@ -580,5 +580,5 @@ } ] }, - "generated_at": "2026-05-23T20:41:24Z" + "generated_at": "2026-05-24T17:39:03Z" } diff --git a/src/api/hive_async_api.py b/src/api/hive_async_api.py index 9223da4..f88ff5e 100644 --- a/src/api/hive_async_api.py +++ b/src/api/hive_async_api.py @@ -9,7 +9,7 @@ from aiohttp import ClientResponse, ClientSession, ClientTimeout, web_exceptions from pyquery import PyQuery -from ..helper.const import HTTP_FORBIDDEN, HTTP_OK, HTTP_UNAUTHORIZED +from ..helper.const import HTTP_FORBIDDEN, HTTP_UNAUTHORIZED from ..helper.hive_exceptions import FileInUse, HiveApiError, HiveAuthError, NoApiToken _LOGGER = logging.getLogger(__name__) @@ -94,14 +94,12 @@ async def request(self, method: str, url: str, **kwargs) -> ClientResponse: raise HiveAuthError( f"Token expired or forbidden calling {url} — HTTP {resp.status}" ) - if url is not None and resp.status is not None: - _LOGGER.error( - "Something has gone wrong calling %s - HTTP status is - %s — response: %s", - url, - resp.status, - resp_body[:200], - ) - + _LOGGER.error( + "Something has gone wrong calling %s - HTTP status is - %s — response: %s", + url, + resp.status, + resp_body[:200], + ) raise HiveApiError def get_login_info(self): @@ -125,32 +123,6 @@ def get_login_info(self): login_data.update({"REGION": json_data["HiveSSOPoolId"]}) return login_data - async def refresh_tokens(self): - """Refresh tokens - DEPRECATED NOW BY AWS TOKEN MANAGEMENT.""" - url = self.urls["refresh"] - tokens = self.session.tokens.token_data if self.session is not None else {} - jsc = ( - "{" - + ",".join( - ('"' + str(i) + '": "' + str(t) + '" ' for i, t in tokens.items()) - ) - + "}" - ) - try: - await self.request("post", url, data=jsc) - - if self.json_return["original"] == HTTP_OK: - info = self.json_return["parsed"] - if "token" in info: - await self.session.update_tokens(info) - # pylint: disable-next=invalid-sequence-index - self.base_url = info["platform"]["endpoint"] - return True - except (ConnectionError, OSError, RuntimeError, ZeroDivisionError): - await self.error() - - return self.json_return - async def get_all(self): """Build and query all endpoint.""" json_return = {} diff --git a/src/api/srp_crypto.py b/src/api/srp_crypto.py index faf141b..74cea33 100644 --- a/src/api/srp_crypto.py +++ b/src/api/srp_crypto.py @@ -1,7 +1,6 @@ """Pure SRP/HKDF crypto helpers for AWS Cognito authentication.""" import binascii -import concurrent.futures import hashlib import hmac import os @@ -28,7 +27,6 @@ # https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L49 G_HEX = "2" INFO_BITS = bytearray("Caldera Derived Key", "utf-8") -POOL = concurrent.futures.ThreadPoolExecutor() def hex_to_long(hex_string): diff --git a/tests/unit/test_hive_api.py b/tests/unit/test_hive_api.py index 8b6a182..1cd1093 100644 --- a/tests/unit/test_hive_api.py +++ b/tests/unit/test_hive_api.py @@ -213,119 +213,6 @@ def test_key_error_calls_error_and_returns_none(self): assert result is None -# --------------------------------------------------------------------------- -# Tests: HiveApi.refresh_tokens -# --------------------------------------------------------------------------- - - -class TestRefreshTokens: - def test_successful_with_token_key_updates_session(self): - """When the response contains 'token', session.update_tokens is called.""" - api = _make_api() - refresh_data = { - "token": "new-token", - "platform": {"endpoint": "https://new.endpoint.com"}, - } - mock_resp = _make_mock_response( - 200, json_data=refresh_data, text=json.dumps(refresh_data) - ) - - with patch.object(api, "request", return_value=mock_resp): - result = api.refresh_tokens() - - api.session.update_tokens.assert_called_once_with(refresh_data) - assert result["original"] == 200 - - def test_no_token_in_response_no_session_update(self): - """When response lacks 'token' key, update_tokens is not called.""" - api = _make_api() - response_data = {"other_key": "value"} - mock_resp = _make_mock_response( - 200, json_data=response_data, text=json.dumps(response_data) - ) - - with patch.object(api, "request", return_value=mock_resp): - api.refresh_tokens() - - api.session.update_tokens.assert_not_called() - - def test_none_tokens_defaults_to_empty_dict(self): - """Calling refresh_tokens() without arguments uses session.token_data.""" - api = _make_api() - response_data = {"other": "val"} - mock_resp = _make_mock_response( - 200, json_data=response_data, text=json.dumps(response_data) - ) - - with patch.object(api, "request", return_value=mock_resp) as mock_req: - api.refresh_tokens() - # Should have been called (session provides the tokens dict) - mock_req.assert_called_once() - - def test_os_error_calls_error(self): - api = _make_api() - with patch.object(api, "request", side_effect=OSError("connection failed")): - api.refresh_tokens() - - assert api.json_return["original"] == "Error making API call" - - def test_runtime_error_calls_error(self): - api = _make_api() - with patch.object(api, "request", side_effect=RuntimeError("fail")): - api.refresh_tokens() - - assert api.json_return["original"] == "Error making API call" - - def test_json_decode_error_calls_error(self): - """Bad JSON in response text triggers error().""" - api = _make_api() - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.text = "not-json" - - with patch.object(api, "request", return_value=mock_resp): - api.refresh_tokens() - - assert api.json_return["original"] == "Error making API call" - - def test_explicit_tokens_arg_skips_none_branch(self): - """Passing a non-None tokens arg covers the 80->82 False branch.""" - api = _make_api() - explicit_tokens = {"key": "val"} - response_data = {"other": "x"} - mock_resp = _make_mock_response(200, json_data=response_data) - - with patch.object(api, "request", return_value=mock_resp): - api.refresh_tokens(tokens=explicit_tokens) - # Session is not None so session tokens overwrite, but no crash - api.session.update_tokens.assert_not_called() - - def test_session_none_skips_token_overwrite(self): - """When session is None the 83->85 False branch is taken (no token overwrite).""" - api = _make_api_no_session(token="standalone-token") - response_data = {"other": "x"} - mock_resp = _make_mock_response(200, json_data=response_data) - - with patch.object(api, "request", return_value=mock_resp): - api.refresh_tokens(tokens={"key": "val"}) - - def test_urls_base_updated_on_token_refresh(self): - """After a successful refresh the base URL is updated from the response.""" - api = _make_api() - refresh_data = { - "token": "new-tok", - "platform": {"endpoint": "https://new-platform.com/1.0"}, - } - mock_resp = _make_mock_response( - 200, json_data=refresh_data, text=json.dumps(refresh_data) - ) - - with patch.object(api, "request", return_value=mock_resp): - api.refresh_tokens() - - assert api.urls["base"] == "https://new-platform.com/1.0" - - # --------------------------------------------------------------------------- # Tests: HiveApi.get_all # --------------------------------------------------------------------------- diff --git a/tests/unit/test_hive_async_api.py b/tests/unit/test_hive_async_api.py index ade420c..c630182 100644 --- a/tests/unit/test_hive_async_api.py +++ b/tests/unit/test_hive_async_api.py @@ -280,20 +280,6 @@ async def test_motion_sensor_returns_parsed_json(self): assert result["parsed"] == payload -# --------------------------------------------------------------------------- -# Tests: HiveApiAsync.refresh_tokens -# --------------------------------------------------------------------------- - - -class TestRefreshTokens: - async def test_no_name_error_when_session_is_none(self): - websession = _make_mock_websession(status=200) - api = HiveApiAsync(hive_session=None, websession=websession) - api.request = AsyncMock() - result = await api.refresh_tokens() - assert result == api.json_return - - # --------------------------------------------------------------------------- # Tests: HiveApiAsync.error # --------------------------------------------------------------------------- diff --git a/tests/unit/test_hive_async_api_extended.py b/tests/unit/test_hive_async_api_extended.py index 4de0e13..eabf190 100644 --- a/tests/unit/test_hive_async_api_extended.py +++ b/tests/unit/test_hive_async_api_extended.py @@ -135,103 +135,6 @@ def test_uses_first_script_tag(self): assert result["UPID"] == "eu-west-1_first" -# --------------------------------------------------------------------------- -# Tests: refresh_tokens() — lines 131-156 -# --------------------------------------------------------------------------- - - -class TestRefreshTokens: - """Cover lines 133-156: refresh_tokens() success, no-token, and error paths.""" - - async def test_successful_request_with_non_ok_json_return_returns_json_return(self): - """When request succeeds but json_return["original"] != HTTP_OK, returns json_return.""" - api = _make_api(status=200) - # request() will succeed (200) but json_return is not updated by refresh_tokens - # so json_return["original"] stays as the default string, not HTTP_OK (200) - result = await api.refresh_tokens() - # Returns self.json_return (the default dict) - assert result == api.json_return - - async def test_session_tokens_read_before_request(self): - """tokens are read from session.tokens.token_data before constructing the request.""" - api = _make_api(status=200, token="my-session-token") - api.session.tokens.token_data = { - "token": "my-session-token", - "refreshToken": "r-tok", - } - result = await api.refresh_tokens() - # No exception raised — tokens were read without error - assert result is not None - - async def test_connection_error_raises_http_error(self): - """ConnectionError inside the try block causes error() → HTTPError.""" - api = _make_api(status=200) - api.websession.request.side_effect = ConnectionError("connection refused") - with pytest.raises(web_exceptions.HTTPError): - await api.refresh_tokens() - - async def test_os_error_raises_http_error(self): - """OSError inside the try block causes error() → HTTPError.""" - api = _make_api(status=200) - api.websession.request.side_effect = OSError("network error") - with pytest.raises(web_exceptions.HTTPError): - await api.refresh_tokens() - - async def test_runtime_error_raises_http_error(self): - """RuntimeError inside the try block causes error() → HTTPError.""" - api = _make_api(status=200) - api.websession.request.side_effect = RuntimeError("bad state") - with pytest.raises(web_exceptions.HTTPError): - await api.refresh_tokens() - - async def test_zero_division_raises_http_error(self): - """ZeroDivisionError inside the try block causes error() → HTTPError.""" - api = _make_api(status=200) - api.websession.request.side_effect = ZeroDivisionError("division by zero") - with pytest.raises(web_exceptions.HTTPError): - await api.refresh_tokens() - - async def test_json_return_true_when_ok_status_in_json_return(self): - """When json_return["original"] equals HTTP_OK (200) and token is present, - update_tokens is called and base_url is updated, returning True.""" - api = _make_api(status=200) - # Manually set json_return to simulate a successful response - api.json_return = { - "original": 200, - "parsed": { - "token": "new-token", - "platform": {"endpoint": "https://new.endpoint"}, - }, - } - api.session.update_tokens = AsyncMock() - - # Patch request to be a no-op (doesn't modify json_return) - with patch.object(api, "request", new_callable=AsyncMock) as mock_req: - mock_req.return_value = MagicMock() - result = await api.refresh_tokens() - - assert result is True - api.session.update_tokens.assert_called_once_with(api.json_return["parsed"]) - assert api.base_url == "https://new.endpoint" - - async def test_json_return_true_without_token_in_parsed(self): - """When json_return["original"] == HTTP_OK but no 'token' in parsed, - update_tokens is NOT called and returns True.""" - api = _make_api(status=200) - api.json_return = { - "original": 200, - "parsed": {"other_key": "value"}, - } - api.session.update_tokens = AsyncMock() - - with patch.object(api, "request", new_callable=AsyncMock) as mock_req: - mock_req.return_value = MagicMock() - result = await api.refresh_tokens() - - assert result is True - api.session.update_tokens.assert_not_called() - - # --------------------------------------------------------------------------- # Tests: motion_sensor() — lines 213-235 # --------------------------------------------------------------------------- @@ -385,48 +288,6 @@ async def test_connection_error_raises_http_error(self): await api.get_weather("?lat=51.5") -# --------------------------------------------------------------------------- -# Tests: request() — url=None and resp.status=None skips the logging branch -# --------------------------------------------------------------------------- - - -class TestRequestUrlOrStatusNone: - """Lines 100->108: when url is None or resp.status is None, skip log → raise directly.""" - - async def test_none_status_skips_log_and_raises_hive_api_error(self): - """resp.status=None causes branch 100->108 (skips the log lines) then raises.""" - api = _make_api(status=200) - # Replace the websession response with one having status=None - bad_resp = _make_mock_response(status=None) - bad_resp.text = AsyncMock(return_value="") - api.websession.request.return_value = bad_resp - with pytest.raises(HiveApiError): - await api.request("get", None) - - -# --------------------------------------------------------------------------- -# Tests: refresh_tokens() — session=None (134->136) -# --------------------------------------------------------------------------- - - -class TestRefreshTokensSessionNone: - """Line 134->136: when self.session is None, skip token_data read (line 135).""" - - async def test_session_none_skips_token_data_read(self): - """When session is None, tokens is not set from session → jsc uses undefined.""" - ws = MagicMock() - ws.request.return_value = _make_mock_response(status=200) - ws.closed = False - ws.close = AsyncMock() - api = HiveApiAsync(hive_session=None, websession=ws) - # tokens is not defined before jsc, so this will raise NameError or UnboundLocalError; - # what we need is that line 134's False branch (134->136) is traversed. - try: - await api.refresh_tokens() - except (NameError, UnboundLocalError, AttributeError): - pass # expected — tokens was never defined since session is None - - # --------------------------------------------------------------------------- # Tests: set_state() JSON encoding — Fix A # --------------------------------------------------------------------------- From f6faebc548d361ba9ee1dd24667d069f6cbdd187 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Sun, 24 May 2026 18:40:45 +0100 Subject: [PATCH 08/29] fix: add device context to bare _LOGGER.error(e) calls in get_mode and boost helpers --- src/devices/boost.py | 4 ++-- src/devices/heating.py | 3 ++- src/devices/hotwater.py | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/devices/boost.py b/src/devices/boost.py index 2e3f1b7..b1e22e9 100644 --- a/src/devices/boost.py +++ b/src/devices/boost.py @@ -29,7 +29,7 @@ async def get_boost_status(self, device: Device): data = self.session.data.products[device.hive_id] return HIVETOHA["Boost"].get(data["state"].get("boost", False), "ON") except KeyError as e: - _LOGGER.error(e) + _LOGGER.error("get_boost_status - KeyError for %s: %s", device.ha_name, e) return None async def get_boost_time(self, device: Device): @@ -43,5 +43,5 @@ async def get_boost_time(self, device: Device): data = self.session.data.products[device.hive_id] return data["state"]["boost"] except KeyError as e: - _LOGGER.error(e) + _LOGGER.error("get_boost_time - KeyError for %s: %s", device.ha_name, e) return None diff --git a/src/devices/heating.py b/src/devices/heating.py index 2b81e1e..2b07d51 100644 --- a/src/devices/heating.py +++ b/src/devices/heating.py @@ -170,6 +170,7 @@ async def get_mode(self, device: Device): """ state = None final = None + device_name = device.ha_name try: data = self.session.data.products[device.hive_id] @@ -178,7 +179,7 @@ async def get_mode(self, device: Device): state = data["props"]["previous"]["mode"] final = HIVETOHA[self.heating_type].get(state, state) except KeyError as e: - _LOGGER.error(e) + _LOGGER.error("get_mode - KeyError getting mode for %s: %s", device_name, e) return final diff --git a/src/devices/hotwater.py b/src/devices/hotwater.py index b6985b2..2de73d9 100644 --- a/src/devices/hotwater.py +++ b/src/devices/hotwater.py @@ -33,6 +33,7 @@ async def get_mode(self, device: Device): """ state = None final = None + device_name = device.ha_name try: data = self.session.data.products[device.hive_id] @@ -41,7 +42,7 @@ async def get_mode(self, device: Device): state = data["props"]["previous"]["mode"] final = HIVETOHA[self.hotwater_type].get(state, state) except KeyError as e: - _LOGGER.error(e) + _LOGGER.error("get_mode - KeyError getting mode for %s: %s", device_name, e) return final From 24612561f1c31edc65944409fdb9bb85cdd1270d Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Sun, 24 May 2026 18:43:30 +0100 Subject: [PATCH 09/29] fix: updateInterval now sets config.scan_interval instead of silently returning True Co-Authored-By: Claude Sonnet 4.6 --- src/helper/compat_aliases.py | 3 +- tests/unit/test_compat_aliases.py | 47 +++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/helper/compat_aliases.py b/src/helper/compat_aliases.py index b1fe79e..e65a485 100644 --- a/src/helper/compat_aliases.py +++ b/src/helper/compat_aliases.py @@ -138,6 +138,7 @@ async def updateData(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for update_data.""" return await self.update_data(device) # type: ignore[attr-defined] - async def updateInterval(self, new_interval: int): # pylint: disable=invalid-name,unused-argument + async def updateInterval(self, new_interval: int): # pylint: disable=invalid-name """Backwards-compatible alias for Home Assistant Scan Interval.""" + self.config.scan_interval = new_interval # type: ignore[attr-defined] return True diff --git a/tests/unit/test_compat_aliases.py b/tests/unit/test_compat_aliases.py index 1c066e7..e1dbd6c 100644 --- a/tests/unit/test_compat_aliases.py +++ b/tests/unit/test_compat_aliases.py @@ -11,7 +11,7 @@ SwitchCompatMixin, WaterHeaterCompatMixin, ) -from apyhiveapi.helper.hivedataclasses import Device +from apyhiveapi.helper.hivedataclasses import Device, SessionConfig def _make_device(): @@ -319,13 +319,56 @@ class Stub(SessionCompatMixin): assert s.deviceList is s.device_list async def test_update_interval_returns_true(self): - """updateInterval always returns True (deprecated no-op).""" + """updateInterval returns True and updates config.scan_interval.""" class Stub(SessionCompatMixin): """Stub for updateInterval test.""" device_list = {} + def __init__(self): + self.config = SessionConfig() + s = Stub() result = await s.updateInterval(60) assert result is True + + +# --------------------------------------------------------------------------- +# SessionCompatMixin.updateInterval — bug fix tests +# --------------------------------------------------------------------------- + + +def _make_concrete_session(): + """Return a minimal SessionCompatMixin subclass with a real SessionConfig.""" + + class ConcreteSession(SessionCompatMixin): + """Minimal concrete SessionCompatMixin for updateInterval tests.""" + + def __init__(self): + self.config = SessionConfig() + self.device_list = {} + + async def start_session(self, config=None): # pylint: disable=unused-argument + """Stub.""" + + async def update_data(self, device): # pylint: disable=unused-argument + """Stub.""" + + return ConcreteSession() + + +class TestSessionCompatMixinUpdateInterval: + """updateInterval must actually update config.scan_interval.""" + + async def test_update_interval_sets_scan_interval(self): + """updateInterval(300) must set self.config.scan_interval = 300.""" + session = _make_concrete_session() + await session.updateInterval(300) + assert session.config.scan_interval == 300 + + async def test_update_interval_returns_true(self): + """updateInterval must return True on success.""" + session = _make_concrete_session() + result = await session.updateInterval(60) + assert result is True From d5154b4e9e2096369263202166cbd91b4265eccb Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Sun, 24 May 2026 18:48:19 +0100 Subject: [PATCH 10/29] fix: wrap updateInterval new_interval in timedelta(seconds=) to match SessionConfig type Co-Authored-By: Claude Sonnet 4.6 --- src/helper/compat_aliases.py | 3 ++- tests/unit/test_compat_aliases.py | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/helper/compat_aliases.py b/src/helper/compat_aliases.py index e65a485..8cdfc0e 100644 --- a/src/helper/compat_aliases.py +++ b/src/helper/compat_aliases.py @@ -7,6 +7,7 @@ from __future__ import annotations +from datetime import timedelta from typing import Any from .hivedataclasses import Device @@ -140,5 +141,5 @@ async def updateData(self, device: Device): # pylint: disable=invalid-name async def updateInterval(self, new_interval: int): # pylint: disable=invalid-name """Backwards-compatible alias for Home Assistant Scan Interval.""" - self.config.scan_interval = new_interval # type: ignore[attr-defined] + self.config.scan_interval = timedelta(seconds=new_interval) # type: ignore[attr-defined] return True diff --git a/tests/unit/test_compat_aliases.py b/tests/unit/test_compat_aliases.py index e1dbd6c..ec6c44e 100644 --- a/tests/unit/test_compat_aliases.py +++ b/tests/unit/test_compat_aliases.py @@ -362,10 +362,12 @@ class TestSessionCompatMixinUpdateInterval: """updateInterval must actually update config.scan_interval.""" async def test_update_interval_sets_scan_interval(self): - """updateInterval(300) must set self.config.scan_interval = 300.""" + """updateInterval(300) must set self.config.scan_interval to timedelta(seconds=300).""" + from datetime import timedelta + session = _make_concrete_session() await session.updateInterval(300) - assert session.config.scan_interval == 300 + assert session.config.scan_interval == timedelta(seconds=300) async def test_update_interval_returns_true(self): """updateInterval must return True on success.""" From 4cd4b6c0310814368e8d9ee651f40b5b0efe63af Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 17:29:42 +0100 Subject: [PATCH 11/29] fix: guard api_resp_d None before dict access; defend homes list against null Co-Authored-By: Claude Sonnet 4.6 --- src/session/polling.py | 8 ++- tests/unit/test_polling.py | 99 ++++++++++++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/src/session/polling.py b/src/session/polling.py index abe5859..283e1aa 100644 --- a/src/session/polling.py +++ b/src/session/polling.py @@ -164,6 +164,8 @@ async def get_devices(self, _n_id: str): # pylint: disable=too-many-locals,too- self.api.get_all, reraise_as=HiveReauthRequired, ) + if api_resp_d is None: + return get_nodes_successful if not str(api_resp_d["original"]).startswith("2"): raise HTTPException if api_resp_d["parsed"] is None: @@ -190,7 +192,11 @@ async def get_devices(self, _n_id: str): # pylint: disable=too-many-locals,too- for a_action in api_resp_p[hive_type_key]: tmp_actions.update({a_action["id"]: a_action}) if hive_type_key == "homes": - self.config.home_id = api_resp_p[hive_type_key]["homes"][0]["id"] + homes_data = api_resp_p[hive_type_key] + if isinstance(homes_data, dict): + homes_list = homes_data.get("homes") or [] + if homes_list: + self.config.home_id = homes_list[0]["id"] _LOGGER.debug( "get_devices - API returned %d products, %d devices, %d actions.", diff --git a/tests/unit/test_polling.py b/tests/unit/test_polling.py index 2a18501..1e55410 100644 --- a/tests/unit/test_polling.py +++ b/tests/unit/test_polling.py @@ -3,6 +3,7 @@ # pylint: disable=protected-access,attribute-defined-outside-init,too-few-public-methods import asyncio +from unittest.mock import AsyncMock, MagicMock from apyhiveapi.helper.hivedataclasses import Device from apyhiveapi.session.polling import PollingMixin @@ -191,8 +192,6 @@ class TestPollDevices: async def test_poll_devices_delegates_to_get_devices(self): """_poll_devices calls get_devices('No_ID') and returns its result.""" - from unittest.mock import AsyncMock - p = _make_polling() p.get_devices = AsyncMock(return_value=True) result = await p._poll_devices() @@ -201,8 +200,6 @@ async def test_poll_devices_delegates_to_get_devices(self): async def test_poll_devices_propagates_false(self): """_poll_devices returns False when get_devices returns False.""" - from unittest.mock import AsyncMock - p = _make_polling() p.get_devices = AsyncMock(return_value=False) result = await p._poll_devices() @@ -216,8 +213,6 @@ async def test_poll_devices_propagates_false(self): class TestGetDevicesSlowPoll: async def test_auth_error_sets_last_poll_slow_false(self): - from unittest.mock import AsyncMock, MagicMock - from apyhiveapi.helper.hive_exceptions import HiveAuthError p = _make_polling() @@ -236,7 +231,7 @@ async def test_auth_error_sets_last_poll_slow_false(self): async def fake_retry_login(): pass - async def fake_retry_with_backoff(_fn, _reraise_as=None): + async def fake_retry_with_backoff(_fn, **_kwargs): return retry_result p._retry_login = fake_retry_login @@ -252,9 +247,20 @@ async def fake_retry_with_backoff(_fn, _reraise_as=None): await p.get_devices("No_ID") assert p._last_poll_slow is False - async def test_slow_api_call_sets_last_poll_slow_true(self): - from unittest.mock import AsyncMock, MagicMock + async def test_tokens_none_returns_false_without_crash(self): + """get_devices returns False (no crash) when tokens=None and file=False.""" + from apyhiveapi.helper.map import Map + p = _make_polling() + p.config = MagicMock() + p.config.file = False + p.tokens = None # triggers the "neither branch" path + p.data = Map({"products": {}, "devices": {}, "actions": {}, "user": {}}) + + result = await p.get_devices("No_ID") + assert result is False + + async def test_slow_api_call_sets_last_poll_slow_true(self): p = _make_polling() p._slow_poll_threshold = 0 # any call will be "slow" p.api = MagicMock() @@ -281,3 +287,78 @@ async def slow_get_all(): await p.get_devices("No_ID") assert p._last_poll_slow is True + + +# --------------------------------------------------------------------------- +# TestGetDevicesNoneGuard — api.get_all() returning None must not crash +# --------------------------------------------------------------------------- + + +class TestGetDevicesNoneGuard: + """api_resp_d must be guarded before dict access when api.get_all() returns None.""" + + async def test_api_returns_none_does_not_crash(self): + """get_devices returns False without crashing when api.get_all() returns None.""" + from apyhiveapi.helper.map import Map + + p = _make_polling() + p.config = MagicMock() + p.config.file = False + p.tokens = MagicMock() + p.api = MagicMock() + p.api.get_all = AsyncMock(return_value=None) + p.hive_refresh_tokens = AsyncMock() + p.data = Map({"products": {}, "devices": {}, "actions": {}, "user": {}}) + + result = await p.get_devices("No_ID") + assert result is False + + +# --------------------------------------------------------------------------- +# TestGetDevicesHomesKey — homes list null/empty must not crash +# --------------------------------------------------------------------------- + + +class TestGetDevicesHomesKey: + """homes key in API response must not crash when homes list is None or empty.""" + + async def _run_get_devices_with_parsed(self, parsed): + from apyhiveapi.helper.map import Map + + p = _make_polling() + p.config = MagicMock() + p.config.file = False + p.tokens = MagicMock() + p.api = MagicMock() + p.api.get_all = AsyncMock(return_value={"original": "200", "parsed": parsed}) + p.hive_refresh_tokens = AsyncMock() + p.data = Map({"products": {}, "devices": {}, "actions": {}, "user": {}}) + return p, await p.get_devices("No_ID") + + async def test_homes_null_does_not_crash(self): + """No crash when API returns homes.homes = None.""" + parsed = { + "products": [], + "devices": [], + "actions": [], + "homes": {"homes": None}, + } + _p, result = await self._run_get_devices_with_parsed(parsed) + assert isinstance(result, bool) + + async def test_homes_empty_list_does_not_crash(self): + """No crash when API returns homes.homes = [].""" + parsed = {"products": [], "devices": [], "actions": [], "homes": {"homes": []}} + _p, result = await self._run_get_devices_with_parsed(parsed) + assert isinstance(result, bool) + + async def test_valid_homes_list_sets_home_id(self): + """home_id is set correctly from a valid homes list.""" + parsed = { + "products": [], + "devices": [], + "actions": [], + "homes": {"homes": [{"id": "home-abc"}]}, + } + p, _ = await self._run_get_devices_with_parsed(parsed) + assert p.config.home_id == "home-abc" From 311a3f39e7514deb52126ebbc0aa6dbdaa8a8fdc Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 17:34:24 +0100 Subject: [PATCH 12/29] fix: raise HiveApiError for unhandled Cognito ClientError codes Unhandled ClientError codes in initiate_auth and respond_to_auth_challenge were silently swallowed, leaving response=None and crashing later with TypeError. async_init would AttributeError on None when get_login_info returned no data. All three now raise HiveApiError/HiveUnknownConfiguration. Co-Authored-By: Claude Sonnet 4.6 --- .secrets.baseline | 14 ++-- src/api/hive_auth_async.py | 8 +- tests/unit/test_hive_auth_async_extended.py | 82 +++++++++++++++++++-- 3 files changed, 89 insertions(+), 15 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 28b888f..c4c8022 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -289,7 +289,7 @@ "filename": "src/api/hive_auth_async.py", "hashed_secret": "576956b5291ac38d04ef5f82cc974286a857f0b2", "is_verified": false, - "line_number": 110 + "line_number": 112 } ], "src/api/srp_crypto.py": [ @@ -516,35 +516,35 @@ "filename": "tests/unit/test_hive_auth_async_extended.py", "hashed_secret": "76f6b6f16cb41692b330fc806029e8a31e20b69b", "is_verified": false, - "line_number": 816 + "line_number": 813 }, { "type": "Secret Keyword", "filename": "tests/unit/test_hive_auth_async_extended.py", "hashed_secret": "b3ed2cf313e7546085c3c50622143ff31e467d23", "is_verified": false, - "line_number": 835 + "line_number": 832 }, { "type": "Secret Keyword", "filename": "tests/unit/test_hive_auth_async_extended.py", "hashed_secret": "7476b69b5005e05d536361f960a9d18b736dfbfc", "is_verified": false, - "line_number": 849 + "line_number": 846 }, { "type": "Secret Keyword", "filename": "tests/unit/test_hive_auth_async_extended.py", "hashed_secret": "ff9f30d9ba5a4ec386edddeacc27f74ef412085e", "is_verified": false, - "line_number": 856 + "line_number": 853 }, { "type": "Secret Keyword", "filename": "tests/unit/test_hive_auth_async_extended.py", "hashed_secret": "a8ad0732120b9dfed5b99fd6a2aca4fc8ba48d80", "is_verified": false, - "line_number": 893 + "line_number": 890 } ], "tests/unit/test_hive_helper_extended.py": [ @@ -580,5 +580,5 @@ } ] }, - "generated_at": "2026-05-24T17:39:03Z" + "generated_at": "2026-05-25T16:34:03Z" } diff --git a/src/api/hive_auth_async.py b/src/api/hive_auth_async.py index 420c47c..0151f6c 100644 --- a/src/api/hive_auth_async.py +++ b/src/api/hive_auth_async.py @@ -91,6 +91,8 @@ async def async_init(self): """Initialise async variables.""" self.loop = asyncio.get_running_loop() self.data = await self.loop.run_in_executor(None, self.api.get_login_info) + if not self.data: + raise HiveUnknownConfiguration("SSO login page returned no data") self._pool_id = self.data.get("UPID") self._client_id = self.data.get("CLIID") region_raw = self.data.get("REGION") @@ -258,7 +260,7 @@ async def process_challenge(self, challenge_parameters): return response - async def login(self): # noqa: PLR0912 + async def login(self): # noqa: PLR0912, PLR0915 # pylint: disable=too-many-statements """Login into a Hive account - handles initial SRP auth only.""" if self.use_file: _LOGGER.debug("login - Using file-based authentication.") @@ -286,6 +288,8 @@ async def login(self): # noqa: PLR0912 if code == "UserNotFoundException": _LOGGER.error("Cognito auth failed: user not found.") raise HiveInvalidUsername from err + _LOGGER.error("Cognito auth failed: %s", code) + raise HiveApiError from err except botocore.exceptions.EndpointConnectionError as err: _LOGGER.error("Cognito auth failed: cannot reach endpoint.") raise HiveApiError from err @@ -315,6 +319,8 @@ async def login(self): # noqa: PLR0912 "Cognito auth challenge failed: device resource not found." ) raise HiveInvalidDeviceAuthentication from err + _LOGGER.error("Cognito auth challenge failed: %s", code) + raise HiveApiError from err except botocore.exceptions.EndpointConnectionError as err: _LOGGER.error("Cognito auth challenge failed: cannot reach endpoint.") raise HiveApiError from err diff --git a/tests/unit/test_hive_auth_async_extended.py b/tests/unit/test_hive_auth_async_extended.py index bc03922..e56ee8b 100644 --- a/tests/unit/test_hive_auth_async_extended.py +++ b/tests/unit/test_hive_auth_async_extended.py @@ -692,7 +692,7 @@ class TestLoginInitiateAuthSwallowedClientError: """Arc 280->288: ClientError caught but class name is not UserNotFoundException.""" async def test_other_client_error_in_initiate_auth_falls_through(self): - """Non-UserNotFoundException ClientError is swallowed; response stays None → TypeError.""" + """Non-UserNotFoundException ClientError raises HiveApiError.""" auth = await _make_auth() wrong_cls = type("SomeOtherError", (botocore.exceptions.ClientError,), {}) @@ -701,9 +701,7 @@ async def test_other_client_error_in_initiate_auth_falls_through(self): ) auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) - # Exception is swallowed; line 288 `response["ChallengeName"]` raises TypeError - # because response is None - with pytest.raises((TypeError, KeyError)): + with pytest.raises(HiveApiError): await auth.login() @@ -738,7 +736,7 @@ class TestLoginChallengeSwallowedClientError: """Arc 307->319: ClientError caught in challenge response with name not matching.""" async def test_other_client_error_in_challenge_falls_through(self): - """ClientError that is neither NotAuthorized nor ResourceNotFound is swallowed.""" + """ClientError that is neither NotAuthorized nor ResourceNotFound raises HiveApiError.""" auth = await _make_auth() challenge_response = { @@ -761,8 +759,7 @@ async def test_other_client_error_in_challenge_falls_through(self): auth.loop.run_in_executor = AsyncMock( side_effect=[challenge_response, wrong_err] ) - # Exception is swallowed; result stays None → TypeError on line 321 - with pytest.raises((TypeError, AttributeError)): + with pytest.raises(HiveApiError): await auth.login() @@ -1019,3 +1016,74 @@ async def test_none_pool_id_raises_configuration_error(self): auth._pool_id = None with pytest.raises(HiveUnknownConfiguration): auth.get_password_authentication_key("user", "pass", "DEADBEEF", "ABCDEF") + + +# --------------------------------------------------------------------------- +# Tests: login() — unhandled ClientError codes must raise HiveApiError +# --------------------------------------------------------------------------- + + +class TestLoginUnhandledClientError: + """Unhandled ClientError codes in login() must raise HiveApiError, not crash.""" + + async def test_initiate_auth_unhandled_error_raises_hive_api_error(self): + """Non-UserNotFoundException ClientError from initiate_auth raises HiveApiError.""" + auth = await _make_auth() + err = botocore.exceptions.ClientError( + {"Error": {"Code": "TooManyRequestsException", "Message": "too many"}}, + "InitiateAuth", + ) + auth.loop.run_in_executor = AsyncMock(side_effect=err) + + with pytest.raises(HiveApiError): + await auth.login() + + async def test_respond_to_challenge_unhandled_error_raises_hive_api_error(self): + """Non-NotAuthorized/ResourceNotFound ClientError raises HiveApiError.""" + auth = await _make_auth() + challenge_response = { + "ChallengeName": "PASSWORD_VERIFIER", + "ChallengeParameters": { + "USER_ID_FOR_SRP": "user@test.com", + "SALT": "aabbccdd", + "SRP_B": "ccddee", + "SECRET_BLOCK": "YWJj", + }, + } + respond_err = botocore.exceptions.ClientError( + {"Error": {"Code": "InternalErrorException", "Message": "internal"}}, + "RespondToAuthChallenge", + ) + auth.loop.run_in_executor = AsyncMock( + side_effect=[challenge_response, respond_err] + ) + auth.process_challenge = AsyncMock( + return_value={"TIMESTAMP": "t", "USERNAME": "u"} + ) + + with pytest.raises(HiveApiError): + await auth.login() + + +# --------------------------------------------------------------------------- +# Tests: async_init() — None return from get_login_info raises HiveUnknownConfiguration +# --------------------------------------------------------------------------- + + +class TestAsyncInitNoneGuard: + """async_init must guard against get_login_info returning None.""" + + async def test_async_init_raises_when_login_info_is_none(self): + """async_init raises HiveUnknownConfiguration when get_login_info returns None.""" + from apyhiveapi.api.hive_auth_async import HiveAuthAsync + from apyhiveapi.helper.hive_exceptions import HiveUnknownConfiguration + + auth = HiveAuthAsync(username="user@test.com", password="pass") + auth.client = None + + mock_loop = MagicMock() + mock_loop.run_in_executor = AsyncMock(return_value=None) + + with patch("asyncio.get_running_loop", return_value=mock_loop): + with pytest.raises(HiveUnknownConfiguration): + await auth.async_init() From e564b8428f97b9e7f3b8e7a5ba633e45b08a8ff1 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 17:36:11 +0100 Subject: [PATCH 13/29] fix: token_created not updated in elif-token branch; bare refreshToken access update_tokens() elif branch never set token_created (leaving it at datetime.min) and crashed on KeyError when refreshToken was absent. hive_refresh_tokens() used bare dict access for refreshToken. Both now use safe access patterns and update token_created consistently. Co-Authored-By: Claude Sonnet 4.6 --- src/session/auth.py | 7 +++- tests/unit/test_session_auth_extended.py | 52 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/session/auth.py b/src/session/auth.py index 1d63f09..82292bd 100644 --- a/src/session/auth.py +++ b/src/session/auth.py @@ -100,8 +100,11 @@ async def update_tokens(self, tokens: dict, update_expiry_time: bool = True): elif "token" in tokens: data = tokens self.tokens.token_data.update({"token": data["token"]}) - self.tokens.token_data.update({"refreshToken": data["refreshToken"]}) + if "refreshToken" in data: + self.tokens.token_data.update({"refreshToken": data["refreshToken"]}) self.tokens.token_data.update({"accessToken": data["accessToken"]}) + if update_expiry_time: + self.tokens.token_created = datetime.now() if "ExpiresIn" in data: self.tokens.token_expiry = timedelta(seconds=data["ExpiresIn"]) @@ -335,7 +338,7 @@ async def hive_refresh_tokens(self, force_refresh: bool = False): ) try: result = await self.auth.refresh_token( - self.tokens.token_data["refreshToken"] + self.tokens.token_data.get("refreshToken") ) if result and "AuthenticationResult" in result: diff --git a/tests/unit/test_session_auth_extended.py b/tests/unit/test_session_auth_extended.py index e688096..acf77b4 100644 --- a/tests/unit/test_session_auth_extended.py +++ b/tests/unit/test_session_auth_extended.py @@ -290,3 +290,55 @@ async def test_force_refresh_enters_lock_even_when_token_is_fresh(self): s.auth.refresh_token.return_value = AUTH_RESULT await s.hive_refresh_tokens(force_refresh=True) s.auth.refresh_token.assert_called_once() + + +# --------------------------------------------------------------------------- +# update_tokens — elif "token" branch missing token_created and bare refreshToken +# --------------------------------------------------------------------------- + + +class TestUpdateTokensTokenBranch: + """update_tokens must set token_created and guard missing refreshToken in elif branch.""" + + async def test_token_branch_sets_token_created(self): + """elif 'token' branch must update token_created (was missing, stayed datetime.min).""" + s = _make_stub() + await s.update_tokens( + {"token": "id-tok", "refreshToken": "ref-tok", "accessToken": "acc-tok"} + ) + assert s.tokens.token_created > datetime.min + + async def test_token_branch_updates_token_data(self): + """elif 'token' branch stores all three token values.""" + s = _make_stub() + await s.update_tokens( + {"token": "id", "refreshToken": "ref", "accessToken": "acc"} + ) + assert s.tokens.token_data["token"] == "id" + assert s.tokens.token_data["refreshToken"] == "ref" + assert s.tokens.token_data["accessToken"] == "acc" + + async def test_token_branch_missing_refresh_token_does_not_crash(self): + """elif 'token' branch without refreshToken key must not raise KeyError.""" + s = _make_stub() + await s.update_tokens({"token": "id", "accessToken": "acc"}) + assert s.tokens.token_data["token"] == "id" + + +# --------------------------------------------------------------------------- +# hive_refresh_tokens — bare refreshToken access raises KeyError when missing +# --------------------------------------------------------------------------- + + +class TestHiveRefreshTokensMissingRefreshToken: + """hive_refresh_tokens must not crash when token_data has no refreshToken.""" + + async def test_missing_refresh_token_does_not_raise_key_error(self): + """hive_refresh_tokens without refreshToken in token_data must not crash.""" + s = _make_stub() + s.tokens.token_data = {"token": "id", "accessToken": "acc"} + s.tokens.token_created = datetime.now() - timedelta(hours=2) + s.tokens.token_expiry = timedelta(hours=1) + s.auth.refresh_token.return_value = None + result = await s.hive_refresh_tokens() + assert result is None From 4f9486d2c793cbb8a333e2b461cd43aa340cd4f9 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 17:37:17 +0100 Subject: [PATCH 14/29] fix: close() must not close a caller-provided websession close() previously closed the websession unconditionally. Added _owns_websession flag (True when websession=None at construction time) so callers who inject their own session retain ownership and lifecycle control. Co-Authored-By: Claude Sonnet 4.6 --- src/session/__init__.py | 5 ++-- tests/unit/test_session_close.py | 41 +++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/session/__init__.py b/src/session/__init__.py index a240306..aefa568 100644 --- a/src/session/__init__.py +++ b/src/session/__init__.py @@ -50,6 +50,7 @@ def __init__( username=username, password=password, ) + self._owns_websession = websession is None self.api = API(hive_session=self, websession=websession) self.helper = HiveHelper(self) self.attr = HiveAttributes(self) @@ -75,8 +76,8 @@ def __init__( self._update_task: asyncio.Task | None = None async def close(self) -> None: - """Close the underlying aiohttp ClientSession.""" - if not self.api.websession.closed: + """Close the underlying aiohttp ClientSession if we own it.""" + if self._owns_websession and not self.api.websession.closed: await self.api.websession.close() async def __aenter__(self): diff --git a/tests/unit/test_session_close.py b/tests/unit/test_session_close.py index 49edabb..17e66d0 100644 --- a/tests/unit/test_session_close.py +++ b/tests/unit/test_session_close.py @@ -9,30 +9,53 @@ class TestHiveSessionClose: """Branch coverage for HiveSession.close() (line 79).""" async def test_close_calls_websession_close_when_not_already_closed(self): - """close() calls websession.close() when websession is open (closed=False). - - Covers the True branch of 'if not self.api.websession.closed'. - """ + """close() calls websession.close() when websession is open (closed=False).""" session = object.__new__(HiveSession) session.api = MagicMock() session.api.websession.closed = False session.api.websession.close = AsyncMock() + session._owns_websession = True await session.close() session.api.websession.close.assert_called_once() async def test_close_skips_websession_close_when_already_closed(self): - """close() does NOT call websession.close() when websession is already closed. - - Covers branch 79->exit: the 'if not closed' condition is False, so the - body is skipped entirely. - """ + """close() does NOT call websession.close() when websession is already closed.""" session = object.__new__(HiveSession) session.api = MagicMock() session.api.websession.closed = True session.api.websession.close = AsyncMock() + session._owns_websession = True + + await session.close() + + session.api.websession.close.assert_not_called() + + +class TestHiveSessionCloseOwnership: + """close() must not close a caller-provided websession.""" + + async def test_close_does_not_close_caller_provided_websession(self): + """When _owns_websession=False, close() must not close the websession.""" + session = object.__new__(HiveSession) + session.api = MagicMock() + session.api.websession.closed = False + session.api.websession.close = AsyncMock() + session._owns_websession = False await session.close() session.api.websession.close.assert_not_called() + + async def test_close_closes_owned_websession(self): + """When _owns_websession=True, close() closes the websession as normal.""" + session = object.__new__(HiveSession) + session.api = MagicMock() + session.api.websession.closed = False + session.api.websession.close = AsyncMock() + session._owns_websession = True + + await session.close() + + session.api.websession.close.assert_called_once() From cd530cd148350f912e96fe5d1c0bf290b3c93017 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 17:41:31 +0100 Subject: [PATCH 15/29] fix: use safe _get_product_state in get_mode BOOST path (heating + hotwater) Bare dict access data["props"]["previous"]["mode"] raised KeyError when previous was absent, silently swallowed by the outer except and logged as an error. Replaced with _get_product_state which returns None safely without triggering the error log path. Co-Authored-By: Claude Sonnet 4.6 --- src/devices/heating.py | 2 +- src/devices/hotwater.py | 2 +- tests/unit/test_heating_extended.py | 37 +++++++++++++++++++++++++++- tests/unit/test_hotwater_extended.py | 24 ++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/devices/heating.py b/src/devices/heating.py index 2b07d51..e2976f4 100644 --- a/src/devices/heating.py +++ b/src/devices/heating.py @@ -176,7 +176,7 @@ async def get_mode(self, device: Device): data = self.session.data.products[device.hive_id] state = data["state"]["mode"] if state == "BOOST": - state = data["props"]["previous"]["mode"] + state = self._get_product_state(device, "props", "previous", "mode") final = HIVETOHA[self.heating_type].get(state, state) except KeyError as e: _LOGGER.error("get_mode - KeyError getting mode for %s: %s", device_name, e) diff --git a/src/devices/hotwater.py b/src/devices/hotwater.py index 2de73d9..9c40e1f 100644 --- a/src/devices/hotwater.py +++ b/src/devices/hotwater.py @@ -39,7 +39,7 @@ async def get_mode(self, device: Device): data = self.session.data.products[device.hive_id] state = data["state"]["mode"] if state == "BOOST": - state = data["props"]["previous"]["mode"] + state = self._get_product_state(device, "props", "previous", "mode") final = HIVETOHA[self.hotwater_type].get(state, state) except KeyError as e: _LOGGER.error("get_mode - KeyError getting mode for %s: %s", device_name, e) diff --git a/tests/unit/test_heating_extended.py b/tests/unit/test_heating_extended.py index e6223dd..be120fb 100644 --- a/tests/unit/test_heating_extended.py +++ b/tests/unit/test_heating_extended.py @@ -2,7 +2,7 @@ # pylint: disable=too-few-public-methods from datetime import datetime -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from apyhiveapi.devices.heating import Climate from apyhiveapi.helper.hivedataclasses import Device, SessionConfig @@ -236,3 +236,38 @@ async def test_offline_returns_none(self): climate.session.attr.online_offline = AsyncMock(return_value=False) result = await climate.get_schedule_now_next_later(_make_device()) assert result is None + + +# --------------------------------------------------------------------------- +# get_mode — BOOST path with missing props.previous must not log error +# --------------------------------------------------------------------------- + + +class TestGetModeBoostMissingPrevious: + """get_mode BOOST path must use safe access, not bare dict that logs a spurious error.""" + + async def test_boost_missing_previous_returns_none_without_error_log(self): + """When mode=BOOST and props has no previous, get_mode returns None without error log.""" + climate = _make_climate({"heat-1": {"state": {"mode": "BOOST"}, "props": {}}}) + d = _make_device() + with patch("apyhiveapi.devices.heating._LOGGER") as mock_log: + result = await climate.get_mode(d) + assert result is None + mock_log.error.assert_not_called() + + async def test_boost_with_previous_mode_returns_mapped_value(self): + """When mode=BOOST and props.previous.mode exists, returns the mapped HA value.""" + from apyhiveapi.helper.const import HIVETOHA + + climate = _make_climate( + { + "heat-1": { + "state": {"mode": "BOOST"}, + "props": {"previous": {"mode": "MANUAL"}}, + } + } + ) + d = _make_device() + result = await climate.get_mode(d) + expected = HIVETOHA["Heating"].get("MANUAL", "MANUAL") + assert result == expected diff --git a/tests/unit/test_hotwater_extended.py b/tests/unit/test_hotwater_extended.py index 45891f7..06f460b 100644 --- a/tests/unit/test_hotwater_extended.py +++ b/tests/unit/test_hotwater_extended.py @@ -160,3 +160,27 @@ async def test_non_schedule_mode_returns_none(self): hw = _make_hotwater({"hw-1": {"state": {"mode": _ON_MODE}}}) result = await hw.get_schedule_now_next_later(_make_device()) assert result is None + + +# --------------------------------------------------------------------------- +# get_mode — BOOST path with missing props.previous must not log error +# --------------------------------------------------------------------------- + + +class TestHotwaterGetModeBoostMissingPrevious: + """get_mode BOOST path must use safe access, not bare dict that logs a spurious error.""" + + async def test_boost_missing_previous_returns_off_without_error_log(self): + """When mode=BOOST and props has no previous, get_mode returns 'OFF' without error log. + + The hotwater HIVETOHA mapping maps None→'OFF', so missing previous + resolves safely instead of throwing a swallowed KeyError. + """ + from unittest.mock import patch + + hw = _make_hotwater({"hw-1": {"state": {"mode": "BOOST"}, "props": {}}}) + d = _make_device() + with patch("apyhiveapi.devices.hotwater._LOGGER") as mock_log: + result = await hw.get_mode(d) + assert result == "OFF" + mock_log.error.assert_not_called() From cffa101ca0aa6b564b5bb21fd8805d2ac916db45 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 17:42:46 +0100 Subject: [PATCH 16/29] fix: set_boost_off returns False instead of sending mode=None to API When props.previous.mode is absent, set_boost_off was calling _execute_state_change(mode=None) causing a silent API failure. Now returns False early with a warning when previous mode cannot be determined. Co-Authored-By: Claude Sonnet 4.6 --- src/devices/heating.py | 6 ++++ src/devices/hotwater.py | 6 ++++ tests/unit/test_heating_extended.py | 41 ++++++++++++++++++++++++++++ tests/unit/test_hotwater_extended.py | 40 ++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/devices/heating.py b/src/devices/heating.py index e2976f4..de265ca 100644 --- a/src/devices/heating.py +++ b/src/devices/heating.py @@ -318,6 +318,12 @@ async def set_boost_off(self, device: Device): "set_boost_off - Setting heating boost OFF for %s.", device.ha_name ) prev_mode = self._get_product_state(device, "props", "previous", "mode") + if prev_mode is None: + _LOGGER.warning( + "set_boost_off - Cannot determine previous mode for %s, skipping.", + device.ha_name, + ) + return False kwargs = {"mode": prev_mode} if prev_mode in ("MANUAL", "OFF"): kwargs["target"] = ( diff --git a/src/devices/hotwater.py b/src/devices/hotwater.py index 9c40e1f..fec0825 100644 --- a/src/devices/hotwater.py +++ b/src/devices/hotwater.py @@ -142,6 +142,12 @@ async def set_boost_off(self, device: Device): "set_boost_off - Setting hot water boost OFF for %s.", device.ha_name ) prev_mode = self._get_product_state(device, "props", "previous", "mode") + if prev_mode is None: + _LOGGER.warning( + "set_boost_off - Cannot determine previous mode for %s, skipping.", + device.ha_name, + ) + return False return await self._execute_state_change(device, mode=prev_mode) diff --git a/tests/unit/test_heating_extended.py b/tests/unit/test_heating_extended.py index be120fb..94d99c5 100644 --- a/tests/unit/test_heating_extended.py +++ b/tests/unit/test_heating_extended.py @@ -271,3 +271,44 @@ async def test_boost_with_previous_mode_returns_mapped_value(self): result = await climate.get_mode(d) expected = HIVETOHA["Heating"].get("MANUAL", "MANUAL") assert result == expected + + +# --------------------------------------------------------------------------- +# set_boost_off — must return False when prev_mode is None (not send mode=None to API) +# --------------------------------------------------------------------------- + + +class TestSetBoostOffNullPrevMode: + """set_boost_off returns False (not an API call) when prev_mode is None.""" + + async def test_set_boost_off_returns_false_when_prev_mode_missing(self): + """set_boost_off returns False and skips _execute_state_change when prev mode absent.""" + from apyhiveapi.devices.heating import HiveHeating + + class StubHeating(HiveHeating): + """Concrete stub for testing.""" + + h = StubHeating() + h.session = MagicMock() + h.session.data.products = { + "h1": { + "state": {"mode": "BOOST"}, + "props": {}, + } + } + h._execute_state_change = AsyncMock(return_value=True) + h.get_boost_status = AsyncMock(return_value="ON") + + d = Device( + hive_id="h1", + hive_name="T", + hive_type="heating", + ha_type="climate", + device_id="d1", + device_name="T", + device_data={"online": True}, + ha_name="Heating", + ) + result = await h.set_boost_off(d) + assert result is False + h._execute_state_change.assert_not_called() diff --git a/tests/unit/test_hotwater_extended.py b/tests/unit/test_hotwater_extended.py index 06f460b..e94a94d 100644 --- a/tests/unit/test_hotwater_extended.py +++ b/tests/unit/test_hotwater_extended.py @@ -170,7 +170,9 @@ async def test_non_schedule_mode_returns_none(self): class TestHotwaterGetModeBoostMissingPrevious: """get_mode BOOST path must use safe access, not bare dict that logs a spurious error.""" - async def test_boost_missing_previous_returns_off_without_error_log(self): + async def test_boost_missing_previous_returns_off_without_error_log( # noqa: E501 + self, + ): """When mode=BOOST and props has no previous, get_mode returns 'OFF' without error log. The hotwater HIVETOHA mapping maps None→'OFF', so missing previous @@ -184,3 +186,39 @@ async def test_boost_missing_previous_returns_off_without_error_log(self): result = await hw.get_mode(d) assert result == "OFF" mock_log.error.assert_not_called() + + +# --------------------------------------------------------------------------- +# set_boost_off — must return False when prev_mode is None (not send mode=None) +# --------------------------------------------------------------------------- + + +class TestHotwaterSetBoostOffNullPrevMode: + """HiveHotwater.set_boost_off returns False when prev_mode is None.""" + + async def test_set_boost_off_returns_false_when_prev_mode_missing(self): + """set_boost_off returns False and does not call API when prev mode is absent.""" + from apyhiveapi.devices.hotwater import HiveHotwater + + class StubHotwater(HiveHotwater): + """Concrete stub for testing.""" + + h = StubHotwater() + h.session = MagicMock() + h.session.data.products = {"h1": {"state": {"mode": "BOOST"}, "props": {}}} + h._execute_state_change = AsyncMock(return_value=True) + h.get_boost_status = AsyncMock(return_value="ON") + + d = Device( + hive_id="h1", + hive_name="T", + hive_type="hotwater", + ha_type="water_heater", + device_id="d1", + device_name="T", + device_data={"online": True}, + ha_name="Hotwater", + ) + result = await h.set_boost_off(d) + assert result is False + h._execute_state_change.assert_not_called() From f42095f50cc869a8ae8ae7af79b8d8024925c8fe Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 17:45:29 +0100 Subject: [PATCH 17/29] fix: return int brightness; return HS 2-tuple from get_color for HA hs_color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brightness calculation returned a float (127.5 for 50%), but HA light entities require int. get_color returned an RGB 3-tuple (r,g,b) but HA hs_color expects (hue_degrees, saturation_percent) — Hive API stores these natively so no conversion is needed. Co-Authored-By: Claude Sonnet 4.6 --- src/devices/color.py | 13 ++++-------- src/devices/light.py | 2 +- tests/module/test_light.py | 12 ++++------- tests/unit/test_color_extended.py | 30 ++++++++++++++++++++++++++ tests/unit/test_light_extended.py | 35 +++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/devices/color.py b/src/devices/color.py index 2442ae9..8a63a9d 100644 --- a/src/devices/color.py +++ b/src/devices/color.py @@ -2,7 +2,6 @@ from __future__ import annotations -import colorsys import logging from typing import Any @@ -72,22 +71,18 @@ async def get_color_temp(self, device: Device): return None async def get_color(self, device: Device): - """Get light current colour as an RGB tuple. + """Get light current colour as an HS tuple for HA hs_color. Args: device (Device): Device to query. Returns: - tuple | None: ``(r, g, b)`` each in 0–255. + tuple | None: ``(hue_degrees, saturation_percent)`` where hue is + 0–360 and saturation is 0–100, or None on error. """ try: data = self.session.data.products[device.hive_id] - hsv = [ - data["state"]["hue"] / 360, - data["state"]["saturation"] / 100, - data["state"]["value"] / 100, - ] - return tuple(int(i * 255) for i in colorsys.hsv_to_rgb(*hsv)) + return (data["state"]["hue"], data["state"]["saturation"]) except KeyError as e: _LOGGER.error(e) return None diff --git a/src/devices/light.py b/src/devices/light.py index b33637e..087cd59 100644 --- a/src/devices/light.py +++ b/src/devices/light.py @@ -62,7 +62,7 @@ async def get_brightness(self, device: Device): try: data = self.session.data.products[device.hive_id] state = data["state"]["brightness"] - final = (state / 100) * 255 + final = int((state / 100) * 255) except KeyError as e: _LOGGER.error( "KeyError getting light brightness for %s: %s", device_name, str(e) diff --git a/tests/module/test_light.py b/tests/module/test_light.py index c28d82e..8910759 100644 --- a/tests/module/test_light.py +++ b/tests/module/test_light.py @@ -1,7 +1,6 @@ """Tests for Light / HiveLight and LightColorHandler.""" # pylint: disable=too-few-public-methods -import colorsys from unittest.mock import AsyncMock, MagicMock from apyhiveapi.devices.light import Light @@ -10,9 +9,9 @@ _HTTP_OK = 200 _BRIGHTNESS_PCT = 50 -_BRIGHTNESS_HA = (_BRIGHTNESS_PCT / 100) * 255 +_BRIGHTNESS_HA = int((_BRIGHTNESS_PCT / 100) * 255) _BRIGHTNESS_RAW = 80 -_BRIGHTNESS_CONVERTED = (_BRIGHTNESS_RAW / 100) * 255 +_BRIGHTNESS_CONVERTED = int((_BRIGHTNESS_RAW / 100) * 255) _BRIGHTNESS_SET = 128 _COLOR_TEMP_KELVIN = 4000 _COLOR_TEMP_MIRED = round((1 / _COLOR_TEMP_KELVIN) * 1_000_000) @@ -23,10 +22,7 @@ _HSV_HUE = 120 _HSV_SAT = 100 _HSV_VAL = 100 -_COLOR_TUPLE = tuple( - int(i * 255) - for i in colorsys.hsv_to_rgb(_HSV_HUE / 360, _HSV_SAT / 100, _HSV_VAL / 100) -) +_COLOR_TUPLE = (_HSV_HUE, _HSV_SAT) def _make_light(products=None, devices=None): @@ -280,7 +276,7 @@ async def test_get_color_temp_missing_returns_none(self): assert await light.get_color_temp(_make_device()) is None async def test_get_color_returns_rgb_tuple(self): - """get_color returns an (R, G, B) tuple in 0–255 range.""" + """get_color returns an (hue, saturation) 2-tuple for HA hs_color.""" light = _make_light( { "light-1": { diff --git a/tests/unit/test_color_extended.py b/tests/unit/test_color_extended.py index b605e0e..dbe830b 100644 --- a/tests/unit/test_color_extended.py +++ b/tests/unit/test_color_extended.py @@ -145,3 +145,33 @@ async def test_get_color_temp_zero_returns_none(self): device = _make_device() result = await h.get_color_temp(device) assert result is None + + +# --------------------------------------------------------------------------- +# get_color — must return HS 2-tuple for HA hs_color, not RGB 3-tuple +# --------------------------------------------------------------------------- + + +class TestGetColorReturnsHSTuple: + """get_color must return (hue_degrees, saturation_percent) 2-tuple for HA hs_color.""" + + async def test_get_color_returns_two_tuple(self): + """get_color returns a 2-tuple (not 3-tuple).""" + session = _make_session( + {"light-1": {"state": {"hue": 120, "saturation": 75, "value": 100}}} + ) + h = _make_handler(session) + device = _make_device() + result = await h.get_color(device) + assert result is not None + assert len(result) == 2, f"Expected 2-tuple (hue, sat), got {result!r}" + + async def test_get_color_returns_correct_hue_and_saturation(self): + """get_color returns (hue, saturation) values matching API data.""" + session = _make_session( + {"light-1": {"state": {"hue": 180, "saturation": 50, "value": 80}}} + ) + h = _make_handler(session) + device = _make_device() + result = await h.get_color(device) + assert result == (180, 50) diff --git a/tests/unit/test_light_extended.py b/tests/unit/test_light_extended.py index d1904e7..d3f2810 100644 --- a/tests/unit/test_light_extended.py +++ b/tests/unit/test_light_extended.py @@ -233,3 +233,38 @@ async def test_turn_on_with_color_calls_set_color(self): call_kwargs = session.api.set_state.call_args.kwargs assert call_kwargs.get("colourMode") == "COLOUR" assert call_kwargs.get("hue") == str(color[0]) + + +# --------------------------------------------------------------------------- +# get_brightness — must return int, not float +# --------------------------------------------------------------------------- + + +class TestGetBrightnessReturnsInt: + """get_brightness must return int, not float.""" + + async def test_get_brightness_returns_int(self): + """Brightness value is returned as int (not float) for HA compatibility.""" + from apyhiveapi.devices.light import HiveLight + + class StubLight(HiveLight): + """Concrete stub for testing.""" + + h = StubLight() + h.session = MagicMock() + h.session.data.products = {"h1": {"state": {"brightness": 50}}} + d = Device( + hive_id="h1", + hive_name="L", + hive_type="warmwhitelight", + ha_type="light", + device_id="d1", + device_name="L", + device_data={"online": True}, + ha_name="Light", + ) + result = await h.get_brightness(d) + assert isinstance(result, int), ( + f"Expected int, got {type(result).__name__}: {result!r}" + ) + assert result == 127 From 38b710c4bdcf3328009c53fec87feec08a6b32da Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 17:47:52 +0100 Subject: [PATCH 18/29] fix: raise HiveUnknownConfiguration (not HiveReauthRequired) for empty device data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty device/product data after polling means configuration is unknown, not that tokens expired — using HiveReauthRequired wrongly triggered a full re-login. Also fixed bare d["id"]/p["id"] access in create_devices to use .get() with a fallback key to avoid KeyError when the id field is absent. Co-Authored-By: Claude Sonnet 4.6 --- src/session/discovery.py | 12 ++- tests/module/test_session_discovery.py | 7 +- tests/unit/test_session_discovery_extended.py | 75 ++++++++++++++++++- 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/src/session/discovery.py b/src/session/discovery.py index 6ff691c..bdefdd7 100644 --- a/src/session/discovery.py +++ b/src/session/discovery.py @@ -8,7 +8,7 @@ from typing import Any from ..helper.const import DEVICES, EXPECTED_DEVICE_DATA_LENGTH, HIVE_TYPES, PRODUCTS -from ..helper.hive_exceptions import HiveReauthRequired, HiveUnknownConfiguration +from ..helper.hive_exceptions import HiveUnknownConfiguration from ..helper.hivedataclasses import Device _DATA_DIR = Path(__file__).parent.parent / "data" @@ -163,10 +163,8 @@ async def start_session(self, config: dict | None = None): await self.get_devices("No_ID") # type: ignore[attr-defined] if not self.data.devices or not self.data.products: - _LOGGER.error( - "No devices or products returned from Hive API, reauthentication required." - ) - raise HiveReauthRequired + _LOGGER.error("No devices or products returned from Hive API.") + raise HiveUnknownConfiguration return await self.create_devices() @@ -237,7 +235,7 @@ async def create_devices( # noqa: PLR0912, PLR0915 ) if device_type in hive_type: - self.config.battery.append(d["id"]) + self.config.battery.append(d.get("id", a_device)) _LOGGER.debug( "create_devices - Added device %s to battery monitoring list", device_name, @@ -313,7 +311,7 @@ async def create_devices( # noqa: PLR0912, PLR0915 ) if product_type in hive_type: - self.config.mode.append(p["id"]) + self.config.mode.append(p.get("id", a_product)) _LOGGER.debug( "create_devices - Added product %s to mode list", product_name ) diff --git a/tests/module/test_session_discovery.py b/tests/module/test_session_discovery.py index 6bc07ea..afdb4ec 100644 --- a/tests/module/test_session_discovery.py +++ b/tests/module/test_session_discovery.py @@ -5,7 +5,6 @@ import pytest from apyhiveapi.helper.hive_exceptions import ( - HiveReauthRequired, HiveUnknownConfiguration, ) from apyhiveapi.helper.hivedataclasses import SessionConfig @@ -103,11 +102,11 @@ async def test_file_mode_username_enables_file_and_succeeds(self): assert s.config.file is True s.get_devices.assert_called_once() - async def test_empty_devices_after_get_devices_raises_reauth(self): - """start_session raises HiveReauthRequired when data.devices is empty post-poll.""" + async def test_empty_devices_after_get_devices_raises_unknown_configuration(self): + """start_session raises HiveUnknownConfiguration when data.devices is empty post-poll.""" s = _make_stub(has_data=False) s.config.file = True - with pytest.raises(HiveReauthRequired): + with pytest.raises(HiveUnknownConfiguration): await s.start_session({}) async def test_no_tokens_in_non_file_config_raises_unknown_configuration(self): diff --git a/tests/unit/test_session_discovery_extended.py b/tests/unit/test_session_discovery_extended.py index 8bc4764..a07ec70 100644 --- a/tests/unit/test_session_discovery_extended.py +++ b/tests/unit/test_session_discovery_extended.py @@ -175,11 +175,11 @@ async def test_no_tokens_and_not_file_raises_unknown_configuration(self): with pytest.raises(HiveUnknownConfiguration): await s.start_session({"username": "user@test.com"}) - async def test_empty_devices_after_get_devices_raises_reauth(self): - """start_session raises HiveReauthRequired when data.devices is empty post-poll.""" + async def test_empty_devices_after_get_devices_raises_unknown_configuration(self): + """start_session raises HiveUnknownConfiguration when data.devices is empty post-poll.""" s = _make_stub(has_data=False) s.config.file = True - with pytest.raises(HiveReauthRequired): + with pytest.raises(HiveUnknownConfiguration): await s.start_session({}) async def test_none_config_defaults_to_empty_dict(self): @@ -310,3 +310,72 @@ async def test_product_with_error_and_valid_both_present_only_valid_added(self): result = await s.create_devices() assert len(result["climate"]) == 1 assert result["climate"][0].hive_id == "good" + + +# --------------------------------------------------------------------------- +# start_session raises wrong exception for empty device data +# --------------------------------------------------------------------------- + + +class TestStartSessionWrongException: + """start_session must raise HiveUnknownConfiguration (not HiveReauthRequired) for empty data.""" + + async def test_empty_devices_raises_unknown_configuration(self): + """start_session raises HiveUnknownConfiguration when API returns no devices.""" + s = _make_stub(has_data=False) + s.get_devices = AsyncMock() + + with pytest.raises(HiveUnknownConfiguration): + await s.start_session({}) + + async def test_does_not_raise_reauth_for_empty_data(self): + """start_session must NOT raise HiveReauthRequired when device data is empty.""" + s = _make_stub(has_data=False) + s.get_devices = AsyncMock() + + with pytest.raises(Exception) as exc_info: + await s.start_session({}) + + assert not isinstance(exc_info.value, HiveReauthRequired), ( + "HiveReauthRequired must not be raised for empty device list" + ) + + +# --------------------------------------------------------------------------- +# create_devices — bare d["id"] and p["id"] crash when id key is absent +# --------------------------------------------------------------------------- + + +class TestBareIdAccess: + """create_devices must use .get('id', fallback) instead of bare ['id'] access.""" + + async def test_battery_device_without_id_does_not_crash(self): + """Device with no 'id' key in battery-type must not raise KeyError.""" + s = _make_create_stub() + s.data["devices"] = { + "trv-key": { + "type": "trv", + "state": {"name": "TRV"}, + "props": {}, + } + } + s.config.battery = [] + try: + await s.create_devices() + except KeyError as err: + pytest.fail(f"KeyError raised for missing 'id' in device: {err}") + + async def test_mode_product_without_id_does_not_crash(self): + """Product with no 'id' key in mode-type must not raise KeyError.""" + s = _make_create_stub() + s.data["products"] = { + "heating-key": { + "type": "heating", + "state": {"name": "Hall"}, + } + } + s.config.mode = [] + try: + await s.create_devices() + except KeyError as err: + pytest.fail(f"KeyError raised for missing 'id' in product: {err}") From 381c78badd1e1f14f3f8c0a2cac14f3433e3b4f7 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 17:53:43 +0100 Subject: [PATCH 19/29] fix: device registration wrong exceptions and unnecessary async methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - confirm_device: non-CodeMismatch ClientErrors now raise HiveApiError instead of being silently swallowed - forget_device: NotAuthorizedException now raises HiveApiError instead of the misleading HiveInvalid2FACode; all ClientErrors propagate - generate_hash_device and get_device_authentication_key are pure computation with no I/O — converted from async def to def; callers updated to remove await Co-Authored-By: Claude Sonnet 4.6 --- .secrets.baseline | 8 +- src/api/device_registration.py | 15 ++- tests/unit/test_device_registration.py | 148 +++++++++++++++++-------- 3 files changed, 115 insertions(+), 56 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index c4c8022..8c40695 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -419,21 +419,21 @@ "filename": "tests/unit/test_device_registration.py", "hashed_secret": "d8bce9746547bb7743e5933fbf0fc4f2d2cbcad3", "is_verified": false, - "line_number": 660 + "line_number": 659 }, { "type": "Secret Keyword", "filename": "tests/unit/test_device_registration.py", "hashed_secret": "e4f50034475acff058e17b35679f8ef1e54f86c5", "is_verified": false, - "line_number": 733 + "line_number": 727 }, { "type": "Secret Keyword", "filename": "tests/unit/test_device_registration.py", "hashed_secret": "6ab013c213c685b1f1b1a452796bf22afbd44699", "is_verified": false, - "line_number": 744 + "line_number": 737 } ], "tests/unit/test_hive_auth.py": [ @@ -580,5 +580,5 @@ } ] }, - "generated_at": "2026-05-25T16:34:03Z" + "generated_at": "2026-05-25T16:53:38Z" } diff --git a/src/api/device_registration.py b/src/api/device_registration.py index c330fe2..316eb3c 100644 --- a/src/api/device_registration.py +++ b/src/api/device_registration.py @@ -57,7 +57,7 @@ class DeviceRegistrationMixin: large_a_value: int client_secret: str | None - async def generate_hash_device(self, device_group_key, device_key): + def generate_hash_device(self, device_group_key, device_key): """Generate device hash key.""" # source: https://github.com/amazon-archives/amazon-cognito-identity-js/blob/6b87f1a30a998072b4d98facb49dcaf8780d15b0/src/AuthenticationHelper.js#L137 # pylint: disable=line-too-long @@ -81,7 +81,7 @@ async def generate_hash_device(self, device_group_key, device_key): self.device_password = device_password return device_secret_verifier_config - async def get_device_authentication_key( # pylint: disable=too-many-positional-arguments + def get_device_authentication_key( # pylint: disable=too-many-positional-arguments self, device_group_key, device_key, device_password, server_b_value, salt ): """Get device authentication key.""" @@ -120,7 +120,7 @@ async def process_device_challenge(self, challenge_parameters): "%a %b %d %H:%M:%S UTC %Y" ), ) - hkdf = await self.get_device_authentication_key( + hkdf = self.get_device_authentication_key( self.device_group_key, self.device_key, self.device_password, @@ -169,7 +169,7 @@ async def confirm_device(self, device_name: str | None = None): result = None try: - device_secret_verifier_config = await self.generate_hash_device( + device_secret_verifier_config = self.generate_hash_device( self.device_group_key, self.device_key ) result = await self.loop.run_in_executor( @@ -184,8 +184,9 @@ async def confirm_device(self, device_name: str | None = None): ) except botocore.exceptions.ClientError as err: code = (err.response or {}).get("Error", {}).get("Code", "") - if code in ("NotAuthorizedException", "CodeMismatchException"): + if code == "CodeMismatchException": raise HiveInvalid2FACode from err + raise HiveApiError from err except botocore.exceptions.EndpointConnectionError as err: raise HiveApiError from err @@ -331,9 +332,7 @@ async def forget_device(self, access_token, device_key): ), ) except botocore.exceptions.ClientError as err: - code = (err.response or {}).get("Error", {}).get("Code", "") - if code == "NotAuthorizedException": - raise HiveInvalid2FACode from err + raise HiveApiError from err except botocore.exceptions.EndpointConnectionError as err: raise HiveApiError from err diff --git a/tests/unit/test_device_registration.py b/tests/unit/test_device_registration.py index 43ad3bc..081b494 100644 --- a/tests/unit/test_device_registration.py +++ b/tests/unit/test_device_registration.py @@ -84,35 +84,35 @@ class StubDRM(DeviceRegistrationMixin): class TestGenerateHashDevice: async def test_returns_verifier_config_with_required_keys(self): stub = await _make_stub() - result = await stub.generate_hash_device("grp-key", "dev-key") + result = stub.generate_hash_device("grp-key", "dev-key") assert "PasswordVerifier" in result assert "Salt" in result async def test_password_verifier_is_non_empty_string(self): stub = await _make_stub() - result = await stub.generate_hash_device("grp-key", "dev-key") + result = stub.generate_hash_device("grp-key", "dev-key") assert isinstance(result["PasswordVerifier"], str) assert len(result["PasswordVerifier"]) > 0 async def test_salt_is_non_empty_string(self): stub = await _make_stub() - result = await stub.generate_hash_device("grp-key", "dev-key") + result = stub.generate_hash_device("grp-key", "dev-key") assert isinstance(result["Salt"], str) assert len(result["Salt"]) > 0 async def test_sets_device_password_on_self(self): stub = await _make_stub() stub.device_password = None - await stub.generate_hash_device("grp-key", "dev-key") + stub.generate_hash_device("grp-key", "dev-key") assert stub.device_password is not None assert isinstance(stub.device_password, str) assert len(stub.device_password) > 0 async def test_different_calls_produce_different_passwords(self): stub = await _make_stub() - await stub.generate_hash_device("grp-key", "dev-key") + stub.generate_hash_device("grp-key", "dev-key") password_first = stub.device_password - await stub.generate_hash_device("grp-key", "dev-key") + stub.generate_hash_device("grp-key", "dev-key") password_second = stub.device_password # Passwords are randomly generated — they should almost never match. # We check they are independently set strings (not None). @@ -121,9 +121,9 @@ async def test_different_calls_produce_different_passwords(self): async def test_different_device_keys_produce_different_verifiers(self): stub = await _make_stub() - result1 = await stub.generate_hash_device("grp-key", "dev-key-1") + result1 = stub.generate_hash_device("grp-key", "dev-key-1") verifier1 = result1["PasswordVerifier"] - result2 = await stub.generate_hash_device("grp-key", "dev-key-2") + result2 = stub.generate_hash_device("grp-key", "dev-key-2") verifier2 = result2["PasswordVerifier"] # Different keys + random passwords → almost certainly different verifiers # (at minimum the structure is valid for both) @@ -173,7 +173,7 @@ async def test_reflects_updated_device_key(self): class TestConfirmDevice: async def test_uses_hostname_when_no_device_name(self): stub = await _make_stub() - stub.generate_hash_device = AsyncMock( + stub.generate_hash_device = MagicMock( return_value={"PasswordVerifier": "pv", "Salt": "s"} ) with patch( @@ -191,7 +191,7 @@ async def test_uses_hostname_when_no_device_name(self): async def test_uses_provided_device_name(self): stub = await _make_stub() - stub.generate_hash_device = AsyncMock( + stub.generate_hash_device = MagicMock( return_value={"PasswordVerifier": "pv", "Salt": "s"} ) await stub.confirm_device("custom-name") @@ -202,27 +202,27 @@ async def test_uses_provided_device_name(self): async def test_returns_executor_result_on_success(self): stub = await _make_stub() - stub.generate_hash_device = AsyncMock( + stub.generate_hash_device = MagicMock( return_value={"PasswordVerifier": "pv", "Salt": "s"} ) stub.loop.run_in_executor.return_value = {"UserConfirmed": True} result = await stub.confirm_device("test-device") assert result == {"UserConfirmed": True} - async def test_not_authorized_raises_invalid_2fa(self): + async def test_not_authorized_raises_api_error(self): stub = await _make_stub() - stub.generate_hash_device = AsyncMock( + stub.generate_hash_device = MagicMock( return_value={"PasswordVerifier": "pv", "Salt": "s"} ) stub.loop.run_in_executor.side_effect = _named_client_error( "NotAuthorizedException" ) - with pytest.raises(HiveInvalid2FACode): + with pytest.raises(HiveApiError): await stub.confirm_device("name") async def test_code_mismatch_raises_invalid_2fa(self): stub = await _make_stub() - stub.generate_hash_device = AsyncMock( + stub.generate_hash_device = MagicMock( return_value={"PasswordVerifier": "pv", "Salt": "s"} ) stub.loop.run_in_executor.side_effect = _named_client_error( @@ -233,7 +233,7 @@ async def test_code_mismatch_raises_invalid_2fa(self): async def test_endpoint_error_raises_api_error(self): stub = await _make_stub() - stub.generate_hash_device = AsyncMock( + stub.generate_hash_device = MagicMock( return_value={"PasswordVerifier": "pv", "Salt": "s"} ) stub.loop.run_in_executor.side_effect = _endpoint_error() @@ -242,7 +242,7 @@ async def test_endpoint_error_raises_api_error(self): async def test_passes_access_token_to_executor(self): stub = await _make_stub(access_token="my-access-token") - stub.generate_hash_device = AsyncMock( + stub.generate_hash_device = MagicMock( return_value={"PasswordVerifier": "pv", "Salt": "s"} ) await stub.confirm_device("dev") @@ -252,7 +252,7 @@ async def test_passes_access_token_to_executor(self): async def test_passes_device_key_to_executor(self): stub = await _make_stub(device_key="my-device-key") - stub.generate_hash_device = AsyncMock( + stub.generate_hash_device = MagicMock( return_value={"PasswordVerifier": "pv", "Salt": "s"} ) await stub.confirm_device("dev") @@ -466,21 +466,20 @@ async def test_passes_access_token_and_device_key_to_executor(self): assert partial_fn.keywords["AccessToken"] == "forget-token" assert partial_fn.keywords["DeviceKey"] == "forget-key" - async def test_not_authorized_raises_invalid_2fa(self): + async def test_not_authorized_raises_api_error(self): stub = await _make_stub() stub.loop.run_in_executor.side_effect = _named_client_error( "NotAuthorizedException" ) - with pytest.raises(HiveInvalid2FACode): + with pytest.raises(HiveApiError): await stub.forget_device("acc-token", "dev-key") - async def test_other_client_error_does_not_raise(self): - """ClientErrors other than NotAuthorizedException are silently swallowed.""" + async def test_other_client_error_raises_api_error(self): + """All ClientErrors raise HiveApiError.""" stub = await _make_stub() stub.loop.run_in_executor.side_effect = _named_client_error("SomeOtherError") - # No exception raised — result will be None - result = await stub.forget_device("acc-token", "dev-key") - assert result is None + with pytest.raises(HiveApiError): + await stub.forget_device("acc-token", "dev-key") async def test_endpoint_error_raises_api_error(self): stub = await _make_stub() @@ -505,7 +504,7 @@ async def test_u_value_zero_raises_value_error(self): stub = await _make_stub() with patch("apyhiveapi.api.device_registration.calculate_u", return_value=0): with pytest.raises(ValueError, match="U cannot be zero"): - await stub.get_device_authentication_key( + stub.get_device_authentication_key( stub.device_group_key, stub.device_key, stub.device_password, @@ -532,7 +531,7 @@ async def fake_init(): stub.client = MagicMock() stub.async_init = fake_init - stub.generate_hash_device = AsyncMock( + stub.generate_hash_device = MagicMock( return_value={"PasswordVerifier": "pv", "Salt": "s"} ) await stub.confirm_device("name") @@ -599,10 +598,10 @@ async def fake_init(): class TestConfirmDeviceSwallowedErrors: - async def test_other_client_error_is_swallowed(self): - """ClientError with an unrecognised class name is caught but not re-raised (184->193).""" + async def test_other_client_error_raises_api_error(self): + """ClientErrors other than CodeMismatchException raise HiveApiError.""" stub = await _make_stub() - stub.generate_hash_device = AsyncMock( + stub.generate_hash_device = MagicMock( return_value={"PasswordVerifier": "pv", "Salt": "s"} ) wrong_cls = type("SomeOtherError", (botocore.exceptions.ClientError,), {}) @@ -610,8 +609,8 @@ async def test_other_client_error_is_swallowed(self): {"Error": {"Code": "SomeOtherError", "Message": "msg"}}, "op" ) stub.loop.run_in_executor.side_effect = wrong_err - result = await stub.confirm_device("name") - assert result is None # no HiveInvalid2FACode raised + with pytest.raises(HiveApiError): + await stub.confirm_device("name") class TestDeviceRegistration: @@ -666,7 +665,6 @@ async def test_returns_response_with_required_keys(self): with patch.object( stub, "get_device_authentication_key", - new_callable=AsyncMock, return_value=fake_hkdf, ): result = await stub.process_device_challenge(self._CHALLENGE_PARAMS) @@ -683,7 +681,6 @@ async def test_username_matches_challenge_parameter(self): with patch.object( stub, "get_device_authentication_key", - new_callable=AsyncMock, return_value=fake_hkdf, ): result = await stub.process_device_challenge(self._CHALLENGE_PARAMS) @@ -696,7 +693,6 @@ async def test_device_key_matches_stub_device_key(self): with patch.object( stub, "get_device_authentication_key", - new_callable=AsyncMock, return_value=fake_hkdf, ): result = await stub.process_device_challenge(self._CHALLENGE_PARAMS) @@ -709,7 +705,6 @@ async def test_secret_block_echoed_back(self): with patch.object( stub, "get_device_authentication_key", - new_callable=AsyncMock, return_value=fake_hkdf, ): result = await stub.process_device_challenge(self._CHALLENGE_PARAMS) @@ -722,7 +717,6 @@ async def test_no_client_secret_no_secret_hash(self): with patch.object( stub, "get_device_authentication_key", - new_callable=AsyncMock, return_value=fake_hkdf, ): result = await stub.process_device_challenge(self._CHALLENGE_PARAMS) @@ -735,7 +729,6 @@ async def test_with_client_secret_adds_secret_hash(self): with patch.object( stub, "get_device_authentication_key", - new_callable=AsyncMock, return_value=fake_hkdf, ): result = await stub.process_device_challenge(self._CHALLENGE_PARAMS) @@ -750,7 +743,6 @@ async def test_timestamp_format_matches_cognito_pattern(self): with patch.object( stub, "get_device_authentication_key", - new_callable=AsyncMock, return_value=fake_hkdf, ): result = await stub.process_device_challenge(self._CHALLENGE_PARAMS) @@ -773,7 +765,6 @@ async def test_password_claim_signature_is_base64_string(self): with patch.object( stub, "get_device_authentication_key", - new_callable=AsyncMock, return_value=fake_hkdf, ): result = await stub.process_device_challenge(self._CHALLENGE_PARAMS) @@ -793,7 +784,6 @@ async def test_salt_as_integer_is_padded(self): with patch.object( stub, "get_device_authentication_key", - new_callable=AsyncMock, return_value=fake_hkdf, ) as mock_auth_key: await stub.process_device_challenge(params) @@ -813,7 +803,7 @@ async def test_returns_16_bytes(self): # Use a valid server_b_value that won't make u_value == 0. # Pick a large prime-ish value that is different from large_a_value. server_b_value = stub.large_a_value + 1 - result = await stub.get_device_authentication_key( + result = stub.get_device_authentication_key( "grp-key", "dev-key", "dev-pass", @@ -826,10 +816,80 @@ async def test_returns_16_bytes(self): async def test_deterministic_for_same_inputs(self): stub = await _make_stub() server_b_value = stub.large_a_value + 1 - result1 = await stub.get_device_authentication_key( + result1 = stub.get_device_authentication_key( "grp-key", "dev-key", "dev-pass", server_b_value, "aabbccdd" ) - result2 = await stub.get_device_authentication_key( + result2 = stub.get_device_authentication_key( "grp-key", "dev-key", "dev-pass", server_b_value, "aabbccdd" ) assert result1 == result2 + + +# --------------------------------------------------------------------------- +# confirm_device and forget_device wrong exception mapping +# --------------------------------------------------------------------------- + + +class TestConfirmDeviceWrongException: + """confirm_device must map NotAuthorizedException → HiveApiError (not HiveInvalid2FACode).""" + + async def test_not_authorized_raises_hive_api_error(self): + """NotAuthorizedException in confirm_device raises HiveApiError, not HiveInvalid2FACode.""" + stub = await _make_stub() + err = botocore.exceptions.ClientError( + {"Error": {"Code": "NotAuthorizedException", "Message": "not auth"}}, + "ConfirmDevice", + ) + stub.loop.run_in_executor = AsyncMock(side_effect=err) + + with pytest.raises(HiveApiError): + await stub.confirm_device() + + async def test_not_authorized_does_not_raise_invalid_2fa(self): + """confirm_device must not raise HiveInvalid2FACode for NotAuthorizedException.""" + stub = await _make_stub() + err = botocore.exceptions.ClientError( + {"Error": {"Code": "NotAuthorizedException", "Message": "not auth"}}, + "ConfirmDevice", + ) + stub.loop.run_in_executor = AsyncMock(side_effect=err) + + with pytest.raises(Exception) as exc_info: + await stub.confirm_device() + + assert not isinstance(exc_info.value, HiveInvalid2FACode) + + +class TestForgetDeviceWrongException: + """forget_device must map NotAuthorizedException → HiveApiError.""" + + async def test_not_authorized_raises_hive_api_error(self): + """NotAuthorizedException in forget_device raises HiveApiError, not HiveInvalid2FACode.""" + stub = await _make_stub() + err = botocore.exceptions.ClientError( + {"Error": {"Code": "NotAuthorizedException", "Message": "not auth"}}, + "ForgetDevice", + ) + stub.loop.run_in_executor = AsyncMock(side_effect=err) + + with pytest.raises(HiveApiError): + await stub.forget_device("tok", "key") + + +# --------------------------------------------------------------------------- +# generate_hash_device — unnecessary async removed +# --------------------------------------------------------------------------- + + +class TestUnnecessaryAsync: + """generate_hash_device should work without async (callable directly).""" + + def test_generate_hash_device_returns_config(self): + """generate_hash_device returns the verifier config without needing await.""" + r = DeviceRegistrationMixin.__new__(DeviceRegistrationMixin) + r.device_password = None + r.g_value = 2 + r.big_n = 0xFFFF + result = r.generate_hash_device("grp", "key") + assert "PasswordVerifier" in result + assert "Salt" in result From 3f3946343b0af2502fe453cce7d2319fb28188a4 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 17:55:35 +0100 Subject: [PATCH 20/29] fix: copy schedule slots in get_schedule_nnl to prevent mutation; guard hotwater schedule NNL - get_schedule_nnl: slot dicts are now shallow-copied before Start_DateTime is added, preventing in-place mutation of the original schedule data that caused stale results on repeated calls - hotwater.get_state: guard snan["now"] access so an empty NNL (fewer than 3 schedule slots) does not raise KeyError; state falls back to the base status value instead Co-Authored-By: Claude Sonnet 4.6 --- src/devices/hotwater.py | 3 +- src/helper/hive_helper.py | 5 ++- tests/unit/test_hive_helper_extended.py | 49 +++++++++++++++++++++++++ tests/unit/test_hotwater_extended.py | 36 ++++++++++++++++++ 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/devices/hotwater.py b/src/devices/hotwater.py index fec0825..d4774c8 100644 --- a/src/devices/hotwater.py +++ b/src/devices/hotwater.py @@ -78,7 +78,8 @@ async def get_state(self, device: Device): snan = self.session.helper.get_schedule_nnl( data["state"]["schedule"] ) - state = snan["now"]["value"]["status"] + if snan and "now" in snan: + state = snan["now"]["value"]["status"] final = HIVETOHA[self.hotwater_type].get(state, state) except KeyError as e: diff --git a/src/helper/hive_helper.py b/src/helper/hive_helper.py index 150a027..8768f1a 100644 --- a/src/helper/hive_helper.py +++ b/src/helper/hive_helper.py @@ -254,8 +254,9 @@ def get_schedule_nnl(self, hive_api_schedule: dict): # pylint: disable=too-many if slot_time_date_dt <= date_time_now: slot_time_date_dt = slot_time_date_dt + datetime.timedelta(days=7) - current_slot_custom["Start_DateTime"] = slot_time_date_dt - full_schedule_list.append(current_slot_custom) + slot_copy = dict(current_slot_custom) + slot_copy["Start_DateTime"] = slot_time_date_dt + full_schedule_list.append(slot_copy) fsl_sorted = sorted( full_schedule_list, diff --git a/tests/unit/test_hive_helper_extended.py b/tests/unit/test_hive_helper_extended.py index f215a8d..c81243d 100644 --- a/tests/unit/test_hive_helper_extended.py +++ b/tests/unit/test_hive_helper_extended.py @@ -135,3 +135,52 @@ def test_to_epoch_standard_hive_format_still_works(self): result = epoch_time("15.06.2024 12:00:00", "%d.%m.%Y %H:%M:%S", "to_epoch") assert isinstance(result, int) assert result > 0 + + +def _sample_schedule(): + """Minimal 7-day schedule with 3 slots on every day.""" + days = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ] + schedule = {} + for d in days: + schedule[d] = [ + {"start": 0, "value": {"status": "ON"}}, + {"start": 480, "value": {"status": "OFF"}}, + {"start": 1200, "value": {"status": "ON"}}, + ] + return schedule + + +class TestGetScheduleNnlMutation: + """get_schedule_nnl must not mutate the input schedule dicts.""" + + def test_second_call_returns_same_result_as_first_call(self): + """Calling get_schedule_nnl twice on the same schedule dict gives consistent results.""" + h = _make_helper() + schedule = _sample_schedule() + + result1 = h.get_schedule_nnl(schedule) + result2 = h.get_schedule_nnl(schedule) + + assert result1.get("now", {}).get("value") == result2.get("now", {}).get( + "value" + ), "Second call returned different 'now' value — schedule was mutated in-place" + + def test_input_schedule_slots_not_modified(self): + """Slot dicts in the input schedule must not gain 'Start_DateTime' after the call.""" + h = _make_helper() + schedule = _sample_schedule() + monday_slot_before = dict(schedule["monday"][0]) + + h.get_schedule_nnl(schedule) + + assert schedule["monday"][0] == monday_slot_before, ( + "get_schedule_nnl mutated the original slot dict" + ) diff --git a/tests/unit/test_hotwater_extended.py b/tests/unit/test_hotwater_extended.py index e94a94d..ddf3d19 100644 --- a/tests/unit/test_hotwater_extended.py +++ b/tests/unit/test_hotwater_extended.py @@ -222,3 +222,39 @@ class StubHotwater(HiveHotwater): result = await h.set_boost_off(d) assert result is False h._execute_state_change.assert_not_called() + + +class TestHotwaterGetStateScheduleGuard: + """get_state handles empty schedule NNL without KeyError.""" + + async def test_get_state_schedule_guard_empty_nnl(self): + """get_state does not crash when get_schedule_nnl returns empty dict.""" + from apyhiveapi.devices.hotwater import HiveHotwater + + class StubHotwater(HiveHotwater): + pass + + h = StubHotwater() + h.session = MagicMock() + h.session.data.products = { + "h1": { + "state": {"status": "ON", "mode": "SCHEDULE", "schedule": {}}, + "props": {}, + } + } + h.session.helper.get_schedule_nnl = MagicMock(return_value={}) + h.get_mode = AsyncMock(return_value="SCHEDULE") + h.get_boost_status = AsyncMock(return_value="OFF") + + d = Device( + hive_id="h1", + hive_name="T", + hive_type="hotwater", + ha_type="water_heater", + device_id="d1", + device_name="T", + device_data={"online": True}, + ha_name="Hotwater", + ) + result = await h.get_state(d) + assert result is None or isinstance(result, str) From 706baa502c1ec84797b82e3b973af05526680145 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 17:59:01 +0100 Subject: [PATCH 21/29] =?UTF-8?q?fix:=20list=E2=86=92set=20for=20battery/m?= =?UTF-8?q?ode,=20remove=20dead=20device=5Fattributes=20code,=20fix=20Map.?= =?UTF-8?q?=5F=5Fdelattr=5F=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SessionConfig.battery and .mode changed from list to set; discovery.py callers updated from .append() to .add() for O(1) membership checks - HiveAttributes.get_mode: removed no-op HIVETOHA["Attribute"] lookup — mode strings never matched boolean keys so the lookup always passed through; now returns raw mode value directly - HiveAttributes.get_battery: removed dead error_check call — battery state is always an integer, never False or "Failed", so error_check never triggered a meaningful action - Map.__delattr__: was raising KeyError for missing keys via dict.__delitem__; now wraps in AttributeError to match Python's attribute protocol Co-Authored-By: Claude Sonnet 4.6 --- src/helper/device_attributes.py | 13 +---- src/helper/hivedataclasses.py | 4 +- src/helper/map.py | 7 ++- src/session/discovery.py | 4 +- tests/unit/test_attributes.py | 54 ++++++++++++++----- tests/unit/test_dataclasses.py | 36 +++++++++++-- tests/unit/test_map.py | 10 ++++ tests/unit/test_session_discovery_extended.py | 4 +- 8 files changed, 97 insertions(+), 35 deletions(-) diff --git a/src/helper/device_attributes.py b/src/helper/device_attributes.py index b703c21..d218a36 100644 --- a/src/helper/device_attributes.py +++ b/src/helper/device_attributes.py @@ -3,8 +3,6 @@ import logging from typing import Any -from .const import HIVETOHA - _LOGGER = logging.getLogger(__name__) @@ -18,7 +16,6 @@ def __init__(self, session: Any = None): session (object, optional): Session to interact with hive account. Defaults to None. """ self.session = session - self.type = "Attribute" async def state_attributes(self, n_id: str, _type: str): """Get HA State Attributes. @@ -70,17 +67,12 @@ async def get_mode(self, n_id: str): Returns: str: The mode of the device. """ - state = None - final = None - try: data = self.session.data.products[n_id] - state = data["state"]["mode"] - final = HIVETOHA[self.type].get(state, state) + return data["state"]["mode"] except KeyError as e: _LOGGER.error(e) - - return final + return None async def get_battery(self, n_id: str): """Get device battery level. @@ -98,7 +90,6 @@ async def get_battery(self, n_id: str): data = self.session.data.devices[n_id] state = data["props"]["battery"] final = state - await self.session.helper.error_check(n_id, self.type, state) except KeyError as e: _LOGGER.error(e) diff --git a/src/helper/hivedataclasses.py b/src/helper/hivedataclasses.py index 19cf3c4..7fe7891 100644 --- a/src/helper/hivedataclasses.py +++ b/src/helper/hivedataclasses.py @@ -102,12 +102,12 @@ class SessionTokens: class SessionConfig: """Typed container for session configuration state.""" - battery: list = field(default_factory=list) + battery: set = field(default_factory=set) error_list: dict = field(default_factory=dict) file: bool = False home_id: str | None = None last_update: datetime = field(default_factory=datetime.now) - mode: list = field(default_factory=list) + mode: set = field(default_factory=set) scan_interval: timedelta = field(default_factory=lambda: _SCAN_INTERVAL) user_id: str | None = None username: str | None = None diff --git a/src/helper/map.py b/src/helper/map.py index 3b38c9d..c6dc468 100644 --- a/src/helper/map.py +++ b/src/helper/map.py @@ -17,4 +17,9 @@ def __getattr__(self, key): raise AttributeError(f"Map has no key {key!r}") from None __setattr__ = dict.__setitem__ - __delattr__ = dict.__delitem__ + + def __delattr__(self, key): + try: + del self[key] + except KeyError: + raise AttributeError(f"Map has no key {key!r}") from None diff --git a/src/session/discovery.py b/src/session/discovery.py index bdefdd7..6d39c64 100644 --- a/src/session/discovery.py +++ b/src/session/discovery.py @@ -235,7 +235,7 @@ async def create_devices( # noqa: PLR0912, PLR0915 ) if device_type in hive_type: - self.config.battery.append(d.get("id", a_device)) + self.config.battery.add(d.get("id", a_device)) _LOGGER.debug( "create_devices - Added device %s to battery monitoring list", device_name, @@ -311,7 +311,7 @@ async def create_devices( # noqa: PLR0912, PLR0915 ) if product_type in hive_type: - self.config.mode.append(p.get("id", a_product)) + self.config.mode.add(p.get("id", a_product)) _LOGGER.debug( "create_devices - Added product %s to mode list", product_name ) diff --git a/tests/unit/test_attributes.py b/tests/unit/test_attributes.py index ab2a075..d39eced 100644 --- a/tests/unit/test_attributes.py +++ b/tests/unit/test_attributes.py @@ -29,8 +29,8 @@ def _make_attrs(devices=None, products=None, battery=None, mode=None): } ) config = SessionConfig() - config.battery = battery or [] - config.mode = mode or [] + config.battery = set(battery) if battery else set() + config.mode = set(mode) if mode else set() session.config = config session.helper = MagicMock() session.helper.error_check = AsyncMock() @@ -92,13 +92,11 @@ async def test_missing_device_returns_none(self): assert await attrs.get_battery("nope") is None @pytest.mark.asyncio - async def test_calls_error_check(self): - """error_check should be called once with the device id, type, and battery level.""" + async def test_returns_correct_battery_level(self): + """Battery level is returned as the raw integer from props.""" attrs = _make_attrs(devices={"d1": {"props": {"battery": BATTERY_50}}}) - await attrs.get_battery("d1") - attrs.session.helper.error_check.assert_awaited_once_with( - "d1", "Attribute", BATTERY_50 - ) + result = await attrs.get_battery("d1") + assert result == BATTERY_50 @pytest.mark.asyncio async def test_battery_zero_returned(self): @@ -136,18 +134,18 @@ async def test_manual_mode_passes_through(self): assert result == "MANUAL" @pytest.mark.asyncio - async def test_true_value_maps_to_online(self): - """HIVETOHA["Attribute"][True] == "Online".""" + async def test_true_value_returned_directly(self): + """get_mode returns the raw mode value (True) without HIVETOHA translation.""" attrs = _make_attrs(products={"p1": {"state": {"mode": True}}}) result = await attrs.get_mode("p1") - assert result == "Online" + assert result is True @pytest.mark.asyncio - async def test_false_value_maps_to_offline(self): - """HIVETOHA["Attribute"][False] == "Offline".""" + async def test_false_value_returned_directly(self): + """get_mode returns the raw mode value (False) without HIVETOHA translation.""" attrs = _make_attrs(products={"p1": {"state": {"mode": False}}}) result = await attrs.get_mode("p1") - assert result == "Offline" + assert result is False # --------------------------------------------------------------------------- @@ -259,3 +257,31 @@ async def test_all_attributes_combined(self): assert result["available"] is True assert result["battery"] == "90%" assert result["mode"] == "SCHEDULE" + + +class TestGetModeNoOp: + """HiveAttributes.get_mode must return mode string directly (no HIVETOHA lookup).""" + + async def test_get_mode_returns_mode_string_unchanged(self): + """get_mode returns the raw mode string, not a boolean-keyed HIVETOHA lookup.""" + session = MagicMock() + session.data.products = {"p1": {"state": {"mode": "SCHEDULE"}}} + attr = HiveAttributes(session) + + result = await attr.get_mode("p1") + assert result == "SCHEDULE" + + +class TestGetBatteryNoDeadCall: + """HiveAttributes.get_battery must not call error_check with integer state.""" + + async def test_get_battery_returns_value_without_error_check_side_effects(self): + """get_battery returns the battery level; error_check is not called with int.""" + session = MagicMock() + session.data.devices = {"d1": {"props": {"battery": 85}}} + attr = HiveAttributes(session) + session.helper.error_check = AsyncMock() + + result = await attr.get_battery("d1") + assert result == 85 + session.helper.error_check.assert_not_called() diff --git a/tests/unit/test_dataclasses.py b/tests/unit/test_dataclasses.py index 5792fd3..e7f9f6c 100644 --- a/tests/unit/test_dataclasses.py +++ b/tests/unit/test_dataclasses.py @@ -112,12 +112,42 @@ def test_default_scan_interval_is_120s(self): c = SessionConfig() assert c.scan_interval == timedelta(seconds=120) - def test_default_battery_is_empty_list(self): - """Test battery defaults to empty list.""" + def test_default_battery_is_empty_set(self): + """Test battery defaults to empty set.""" c = SessionConfig() - assert c.battery == [] + assert c.battery == set() def test_username_stored(self): """Test username can be set and retrieved.""" c = SessionConfig(username="user@example.com") assert c.username == "user@example.com" + + +class TestSessionConfigCollectionTypes: + """battery and mode must be sets for O(1) membership checks.""" + + def test_battery_is_set(self): + """SessionConfig.battery is a set (not a list).""" + config = SessionConfig() + assert isinstance(config.battery, set), ( + f"Expected set, got {type(config.battery).__name__}" + ) + + def test_mode_is_set(self): + """SessionConfig.mode is a set (not a list).""" + config = SessionConfig() + assert isinstance(config.mode, set), ( + f"Expected set, got {type(config.mode).__name__}" + ) + + def test_battery_supports_membership_check(self): + """Can check membership in battery using 'in' after add.""" + config = SessionConfig() + config.battery.add("d1") + assert "d1" in config.battery + + def test_mode_supports_membership_check(self): + """Can check membership in mode using 'in' after add.""" + config = SessionConfig() + config.mode.add("p1") + assert "p1" in config.mode diff --git a/tests/unit/test_map.py b/tests/unit/test_map.py index 0cd5350..ee9ab44 100644 --- a/tests/unit/test_map.py +++ b/tests/unit/test_map.py @@ -41,3 +41,13 @@ def test_attribute_write(): m = Map({}) m.foo = "bar" assert m["foo"] == "bar" + + +class TestMapDelAttr: + """Map.__delattr__ must raise AttributeError (not KeyError) for missing keys.""" + + def test_delattr_missing_key_raises_attribute_error(self): + """del m.missing raises AttributeError, not KeyError.""" + m = Map({"a": 1}) + with pytest.raises(AttributeError): + del m.nonexistent diff --git a/tests/unit/test_session_discovery_extended.py b/tests/unit/test_session_discovery_extended.py index a07ec70..a9d9aad 100644 --- a/tests/unit/test_session_discovery_extended.py +++ b/tests/unit/test_session_discovery_extended.py @@ -359,7 +359,7 @@ async def test_battery_device_without_id_does_not_crash(self): "props": {}, } } - s.config.battery = [] + s.config.battery = set() try: await s.create_devices() except KeyError as err: @@ -374,7 +374,7 @@ async def test_mode_product_without_id_does_not_crash(self): "state": {"name": "Hall"}, } } - s.config.mode = [] + s.config.mode = set() try: await s.create_devices() except KeyError as err: From c269ca0cdbbe708e60128ecff927bf8c037db5f0 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 19:34:53 +0100 Subject: [PATCH 22/29] test: cover remaining branch gaps in session/auth and session/polling - update_tokens elif-'token' branch with update_expiry_time=False: token_created must not be updated (106->109 branch was uncovered) - get_devices with homes_data as a non-dict (list): must not crash and home_id stays unset (196->181 branch was uncovered) Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_polling.py | 11 +++++++++++ tests/unit/test_session_auth_extended.py | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/tests/unit/test_polling.py b/tests/unit/test_polling.py index 1e55410..77062a0 100644 --- a/tests/unit/test_polling.py +++ b/tests/unit/test_polling.py @@ -362,3 +362,14 @@ async def test_valid_homes_list_sets_home_id(self): } p, _ = await self._run_get_devices_with_parsed(parsed) assert p.config.home_id == "home-abc" + + async def test_homes_data_not_dict_does_not_crash(self): + """No crash when homes_data is a non-dict (list); home_id stays unset.""" + parsed = { + "products": [], + "devices": [], + "actions": [], + "homes": [{"id": "home-list"}], + } + _p, result = await self._run_get_devices_with_parsed(parsed) + assert isinstance(result, bool) diff --git a/tests/unit/test_session_auth_extended.py b/tests/unit/test_session_auth_extended.py index acf77b4..893753d 100644 --- a/tests/unit/test_session_auth_extended.py +++ b/tests/unit/test_session_auth_extended.py @@ -324,6 +324,17 @@ async def test_token_branch_missing_refresh_token_does_not_crash(self): await s.update_tokens({"token": "id", "accessToken": "acc"}) assert s.tokens.token_data["token"] == "id" + async def test_token_branch_update_expiry_false_does_not_update_token_created(self): + """update_expiry_time=False skips the token_created assignment in elif 'token' branch.""" + + s = _make_stub() + original_created = s.tokens.token_created + await s.update_tokens( + {"token": "id", "accessToken": "acc"}, + update_expiry_time=False, + ) + assert s.tokens.token_created == original_created + # --------------------------------------------------------------------------- # hive_refresh_tokens — bare refreshToken access raises KeyError when missing From 1a0fe4a12da4ce7796fc090b8e579ae695304025 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 19:37:46 +0100 Subject: [PATCH 23/29] refactor: remove deprecated refresh_tokens from HiveApi The method was never called by any production or test code and was explicitly marked as superseded by AWS token management. Removing it brings coverage to 100%. Co-Authored-By: Claude Sonnet 4.6 --- src/api/hive_api.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/src/api/hive_api.py b/src/api/hive_api.py index 6801d3f..5cd7987 100644 --- a/src/api/hive_api.py +++ b/src/api/hive_api.py @@ -74,38 +74,6 @@ def request(self, http_method, url, jsc=None): _LOGGER.error("Request failed: %s", e) raise - def refresh_tokens(self, tokens=None): - """Get new session tokens - DEPRECATED NOW BY AWS TOKEN MANAGEMENT.""" - _LOGGER.debug("refresh_tokens - Attempting token refresh (deprecated method)") - if tokens is None: - tokens = {} - url = self.urls["refresh"] - if self.session is not None: - tokens = self.session.tokens.token_data - jsc = ( - "{" - + ",".join( - ('"' + str(i) + '": "' + str(t) + '" ' for i, t in tokens.items()) - ) - + "}" - ) - try: - info = self.request("POST", url, jsc) - data = json.loads(info.text) - if "token" in data and self.session: - _LOGGER.debug( - "refresh_tokens - Token refresh successful, updating session" - ) - self.session.update_tokens(data) - self.urls.update({"base": data["platform"]["endpoint"]}) - self.json_return.update({"original": info.status_code}) - self.json_return.update({"parsed": info.json()}) - except (OSError, RuntimeError, ZeroDivisionError, json.JSONDecodeError) as e: - _LOGGER.error("Token refresh failed: %s", str(e)) - self.error() - - return self.json_return - def get_login_info(self): """Get login properties to make the login request.""" _LOGGER.debug( From 5c9830a2175d3bef0e922fb2f7b775c9a9cb828a Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Mon, 25 May 2026 19:59:26 +0100 Subject: [PATCH 24/29] test: remove redundant tests from catch-all and relocate unique ones Deleted ~20 tests from test_remaining_branches.py that were exact duplicates of tests in their dedicated _extended files. Moved the two genuinely unique tests (get_state exception handler and dict-under- sensitive-key sanitize path) to test_heating_extended.py and test_helpers.py respectively. Coverage remains at 100%. Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_heating_extended.py | 28 +++ tests/unit/test_helpers.py | 8 + tests/unit/test_remaining_branches.py | 265 -------------------------- 3 files changed, 36 insertions(+), 265 deletions(-) diff --git a/tests/unit/test_heating_extended.py b/tests/unit/test_heating_extended.py index 94d99c5..ab375f6 100644 --- a/tests/unit/test_heating_extended.py +++ b/tests/unit/test_heating_extended.py @@ -151,6 +151,34 @@ async def test_none_temps_returns_none(self): result = await climate.get_state(_make_device()) assert result is None + async def test_key_error_in_get_current_temperature_is_caught(self): + """KeyError from get_current_temperature is caught; get_state returns None.""" + climate = _make_climate( + {"heat-1": {"state": {"mode": "MANUAL", "target": 20.0}, "props": {}}} + ) + with patch.object( + climate, "get_current_temperature", new_callable=AsyncMock + ) as mock_t: + mock_t.side_effect = KeyError("missing_key") + result = await climate.get_state(_make_device()) + assert result is None + + async def test_type_error_in_get_target_temperature_is_caught(self): + """TypeError from get_target_temperature is caught; get_state returns None.""" + climate = _make_climate( + {"heat-1": {"state": {"mode": "MANUAL", "target": 20.0}, "props": {}}} + ) + with patch.object( + climate, "get_current_temperature", new_callable=AsyncMock + ) as mock_cur: + mock_cur.return_value = 19.0 + with patch.object( + climate, "get_target_temperature", new_callable=AsyncMock + ) as mock_tgt: + mock_tgt.side_effect = TypeError("bad type") + result = await climate.get_state(_make_device()) + assert result is None + class TestGetCurrentOperation: async def test_returns_working_state(self): diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index 9f9578f..c07ad01 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -194,6 +194,14 @@ def test_non_string_value_under_sensitive_key_passes_through(self): result = helper.sanitize_payload({"token": _non_string_int}) assert result["token"] == _non_string_int + def test_dict_under_sensitive_key_is_recursively_masked(self): + """A dict value under a sensitive key has its own values masked.""" + helper, _ = _make_helper() + result = helper.sanitize_payload({"token": {"inner_key": "secret_value"}}) + assert isinstance(result["token"], dict) + assert "inner_key" in result["token"] + assert result["token"]["inner_key"] != "secret_value" + # --------------------------------------------------------------------------- # HiveHelper.device_recovered diff --git a/tests/unit/test_remaining_branches.py b/tests/unit/test_remaining_branches.py index 2b652d8..14493c1 100644 --- a/tests/unit/test_remaining_branches.py +++ b/tests/unit/test_remaining_branches.py @@ -273,27 +273,6 @@ async def test_get_heat_on_demand_returns_none_when_missing(self): assert result is None -class TestHeatingSetBoostOffOffMode: - """Lines 321->325: set_boost_off when previous mode is 'OFF' with a real target.""" - - async def test_set_boost_off_off_mode_with_target_restores_target(self): - """Previous mode OFF with a real target value restores that target.""" - climate = _make_climate( - { - "heat-1": { - "type": "heating", - "state": {"boost": 5}, - "props": {"previous": {"mode": "OFF", "target": 18.0}}, - } - } - ) - result = await climate.set_boost_off(_make_device()) - assert result is True - _, kwargs = climate.session.api.set_state.call_args - assert kwargs.get("mode") == "OFF" - assert kwargs.get("target") == 18.0 - - class TestHeatingSetHeatOnDemand: """Lines 337-342: set_heat_on_demand calls _execute_state_change with autoBoost kwarg.""" @@ -315,21 +294,6 @@ async def test_set_heat_on_demand_disabled(self): assert kwargs.get("autoBoost") == "DISABLED" -class TestHeatingGetClimateCacheHit: - """Lines 371->377: get_climate returns cached device when cache is available.""" - - async def test_get_climate_returns_cached_when_available(self): - """Cache hit short-circuits all I/O and returns the cached dict.""" - climate = _make_climate({"heat-1": {"type": "heating"}}) - cached = {"current_temperature": 20.0} - climate.session.should_use_cached_data.return_value = True - climate.session.get_cached_device.return_value = cached - result = await climate.get_climate(_make_device()) - assert result == cached - # No API calls should have been made - climate.session.attr.online_offline.assert_not_called() - - class TestHeatingGetScheduleNNLKeyError: """Lines 438-439: KeyError in get_schedule_now_next_later.""" @@ -402,20 +366,6 @@ async def test_get_state_missing_schedule_in_schedule_mode_returns_none(self): assert result is None -class TestHotwaterGetWaterHeaterCacheHit: - """Lines 173->180: get_water_heater returns cached when cache is available.""" - - async def test_get_water_heater_returns_cached(self): - """Cache hit short-circuits all I/O and returns the cached value.""" - hw = _make_hotwater() - cached = {"current_operation": "ON"} - hw.session.should_use_cached_data.return_value = True - hw.session.get_cached_device.return_value = cached - result = await hw.get_water_heater(_make_hw_device()) - assert result == cached - hw.session.attr.online_offline.assert_not_called() - - class TestHotwaterScheduleNNLNone: """Lines 225->227: get_schedule_now_next_later returns None when schedule is absent.""" @@ -426,24 +376,6 @@ async def test_schedule_none_when_no_schedule_in_state(self): result = await hw.get_schedule_now_next_later(_make_hw_device()) assert result is None - async def test_non_schedule_mode_returns_none(self): - """Non-SCHEDULE mode skips the schedule lookup and returns None directly.""" - hw = _make_hotwater({"hw-1": {"state": {"mode": "MANUAL"}}}) - result = await hw.get_schedule_now_next_later(_make_hw_device()) - assert result is None - - async def test_schedule_present_returns_nnl(self): - """When schedule data exists, get_schedule_nnl result is returned.""" - schedule_data = {"foo": "bar"} - hw = _make_hotwater( - {"hw-1": {"state": {"mode": "SCHEDULE", "schedule": schedule_data}}} - ) - expected = {"now": {}, "next": {}, "later": {}} - hw.session.helper.get_schedule_nnl.return_value = expected - result = await hw.get_schedule_now_next_later(_make_hw_device()) - assert result == expected - hw.session.helper.get_schedule_nnl.assert_called_once_with(schedule_data) - # =========================================================================== # 3. src/devices/sensor.py @@ -468,109 +400,6 @@ async def test_get_state_missing_props_key_returns_none(self): assert result is None -class TestSensorGetSensorCacheHit: - """Lines 92->98: get_sensor returns cached device when cache is available.""" - - async def test_get_sensor_returns_cached(self): - """Cache hit short-circuits all I/O and returns the cached value.""" - sensor = _make_sensor() - cached = {"state": True} - sensor.session.should_use_cached_data.return_value = True - sensor.session.get_cached_device.return_value = cached - result = await sensor.get_sensor(_make_sensor_device()) - assert result == cached - sensor.session.attr.online_offline.assert_not_called() - - -class TestSensorGetSensorProductsFallback: - """Lines 119->122: when device_id not in devices, fall back to products.""" - - async def test_uses_products_when_device_id_absent_from_devices(self): - """device_id not in session.data.devices → hive_id looked up in products.""" - sensor = _make_sensor( - products={"sens-1": {"type": "contactsensor", "props": {"status": "OPEN"}}}, - devices={}, - ) - d = _make_sensor_device( - hive_id="sens-1", device_id="unknown-dev", hive_type="contactsensor" - ) - # Sensor with hive_type in sensor_commands path will be followed; - # the important thing is the products-fallback path is entered without error. - result = await sensor.get_sensor(d) - # Result should be the device (set_cached_device returns the device itself) - assert result is not None - - async def test_products_fallback_data_used_for_device_data(self): - """Props from the products entry propagate to device.device_data.""" - sensor = _make_sensor( - products={ - "sens-1": { - "type": "contactsensor", - "props": {"status": "OPEN", "online": True}, - } - }, - devices={}, - ) - d = _make_sensor_device( - hive_id="sens-1", device_id="unknown-dev", hive_type="contactsensor" - ) - await sensor.get_sensor(d) - # get_state uses self.session.data.products[device.hive_id] directly - # so we just verify it ran without KeyError - - -class TestSensorGetSensorHiveTypesSensorPath: - """Lines 135->146: elif device.hive_type in HIVE_TYPES['Sensor'] path.""" - - async def test_contactsensor_in_hive_types_sensor_takes_else_branch(self): - """contactsensor is in HIVE_TYPES['Sensor'] and not in sensor_commands key set, - so the elif branch is taken.""" - from apyhiveapi.devices.sensor import sensor_commands - from apyhiveapi.helper.const import HIVE_TYPES - - # 'contactsensor' is in HIVE_TYPES['Sensor'] and NOT a key in sensor_commands - assert "contactsensor" in HIVE_TYPES["Sensor"] - assert "contactsensor" not in sensor_commands - - sensor = _make_sensor( - products={"sens-1": {"type": "contactsensor", "props": {"status": "OPEN"}}}, - devices={"dev-1": {"props": {"online": True}, "type": "contactsensor"}}, - ) - d = _make_sensor_device( - hive_id="sens-1", device_id="dev-1", hive_type="contactsensor" - ) - d.device_data = {"online": True} - await sensor.get_sensor(d) - # The elif branch sets device.status with 'state' key - assert d.status is not None - assert "state" in d.status - - async def test_motionsensor_in_hive_types_sensor_sets_status(self): - """motionsensor is in HIVE_TYPES['Sensor'] and not in sensor_commands key set.""" - from apyhiveapi.devices.sensor import sensor_commands - from apyhiveapi.helper.const import HIVE_TYPES - - assert "motionsensor" in HIVE_TYPES["Sensor"] - assert "motionsensor" not in sensor_commands - - sensor = _make_sensor( - products={ - "sens-1": { - "type": "motionsensor", - "props": {"motion": {"status": True}}, - } - }, - devices={"dev-1": {"props": {"online": True}, "type": "motionsensor"}}, - ) - d = _make_sensor_device( - hive_id="sens-1", device_id="dev-1", hive_type="motionsensor" - ) - d.device_data = {"online": True} - await sensor.get_sensor(d) - assert d.status is not None - assert "state" in d.status - - # =========================================================================== # 4. src/session/auth.py # =========================================================================== @@ -1205,66 +1034,6 @@ def test_trv_without_zone_does_not_log_warning(self, caplog): f"Unexpected warnings: {[r.getMessage() for r in caplog.records]}" ) - def test_zone_match_replaces_device_with_thermostat(self): - """Matching zones cause device to be replaced with the thermostat entry.""" - helper = HiveHelper(session=MagicMock()) - thermo_data = { - "type": "thermostatui", - "props": {"zone": "zone-X"}, - } - helper.session.data = Map( - { - "devices": {"thermo-1": thermo_data}, - "products": {}, - "actions": {}, - "user": {}, - "minMax": {}, - } - ) - - product = { - "type": "heating", - "id": "prod-1", - "props": {"zone": "zone-X"}, # matching zone - } - - result = helper.get_device_data(product) - assert result is thermo_data - - -class TestHiveHelperSanitizeDictValue: - """hive_helper.py line 328: dict value under a sensitive key calls _mask(dict).""" - - def test_dict_under_sensitive_key_is_recursively_masked(self): - """A dict value under 'token' key hits the isinstance(value, dict) branch.""" - helper = HiveHelper() - result = helper.sanitize_payload({"token": {"inner_key": "secret_value"}}) - # 'token' is sensitive → _mask is called with the nested dict - # _mask for a dict returns {k: _mask(v) for k, v in value.items()} - # _mask("secret_value") → "sec...lue" (long enough) or "***" - assert "token" in result - assert isinstance(result["token"], dict) - assert "inner_key" in result["token"] - # The inner value should be masked (not the original) - assert result["token"]["inner_key"] != "secret_value" - - def test_nested_dict_keys_preserved_after_masking(self): - """Keys inside a sensitive dict are preserved, values are masked.""" - helper = HiveHelper() - result = helper.sanitize_payload( - { - "authenticationresult": { - "AccessToken": "long-secret-token-value", - "ExpiresIn": 3600, - } - } - ) - inner = result["authenticationresult"] - assert "AccessToken" in inner - assert "ExpiresIn" in inner - # ExpiresIn is an int, _mask returns it as-is - assert inner["ExpiresIn"] == 3600 - class TestHiveHelperSanitizeListNode: """hive_helper.py line 359: list value under a non-sensitive key calls _walk(list).""" @@ -1296,37 +1065,3 @@ def test_list_containing_dicts_is_walked_recursively(self): assert result["items"][0]["token"] != "abc" assert result["items"][1]["name"] == "device2" assert result["items"][1]["token"] != "xyz" - - -class TestHeatingGetStateExceptionCaught: - """heating.py lines 206-207: except (KeyError, TypeError) handler is reached.""" - - async def test_key_error_in_get_current_temperature_is_caught(self): - """KeyError raised by get_current_temperature is caught, final stays None.""" - climate = _make_climate( - {"heat-1": {"state": {"mode": "MANUAL", "target": 20.0}, "props": {}}} - ) - d = _make_device() - with patch.object( - climate, "get_current_temperature", new_callable=AsyncMock - ) as mock_t: - mock_t.side_effect = KeyError("missing_key") - result = await climate.get_state(d) - assert result is None - - async def test_type_error_in_get_target_temperature_is_caught(self): - """TypeError raised by get_target_temperature is caught, final stays None.""" - climate = _make_climate( - {"heat-1": {"state": {"mode": "MANUAL", "target": 20.0}, "props": {}}} - ) - d = _make_device() - with patch.object( - climate, "get_current_temperature", new_callable=AsyncMock - ) as mock_cur: - mock_cur.return_value = 19.0 - with patch.object( - climate, "get_target_temperature", new_callable=AsyncMock - ) as mock_tgt: - mock_tgt.side_effect = TypeError("bad type") - result = await climate.get_state(d) - assert result is None From 90c978fcb8109c48c0b991d515563b000188d9be Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Wed, 27 May 2026 18:17:04 +0100 Subject: [PATCH 25/29] test: migrate all unique tests from catch-all into dedicated files Moves all remaining unique test classes from test_remaining_branches.py into their respective device/session/helper extended files. Deletes the empty catch-all. Coverage remains at 100%. Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_heating_extended.py | 128 ++ tests/unit/test_helpers.py | 90 ++ tests/unit/test_hotwater_extended.py | 69 ++ tests/unit/test_light_extended.py | 24 + tests/unit/test_remaining_branches.py | 1067 ----------------- tests/unit/test_sensor_extended.py | 87 ++ tests/unit/test_session_auth_extended.py | 127 ++ tests/unit/test_session_discovery_extended.py | 213 +++- 8 files changed, 736 insertions(+), 1069 deletions(-) delete mode 100644 tests/unit/test_remaining_branches.py diff --git a/tests/unit/test_heating_extended.py b/tests/unit/test_heating_extended.py index ab375f6..708388e 100644 --- a/tests/unit/test_heating_extended.py +++ b/tests/unit/test_heating_extended.py @@ -4,6 +4,7 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch +import pytest from apyhiveapi.devices.heating import Climate from apyhiveapi.helper.hivedataclasses import Device, SessionConfig from apyhiveapi.helper.map import Map @@ -340,3 +341,130 @@ class StubHeating(HiveHeating): result = await h.set_boost_off(d) assert result is False h._execute_state_change.assert_not_called() + + +# =========================================================================== +# Migrated from test_remaining_branches.py +# =========================================================================== + + +class TestHeatingGetStateKeyError: + """Lines 206-207: KeyError/TypeError branch in get_state.""" + + async def test_get_state_key_error_returns_none(self): + """Missing product entry causes get_current_temperature to return None, + leaving final as None without raising.""" + # products dict is empty — device.hive_id not found → both temp helpers + # return None → the if branch is skipped → final stays None + climate = _make_climate(products={}) + d = _make_device() + result = await climate.get_state(d) + assert result is None + + +class TestHeatingGetHeatOnDemand: + """Line 231: get_heat_on_demand happy path.""" + + async def test_get_heat_on_demand_returns_value(self): + """Returns the nested autoBoost.active value from products.""" + climate = _make_climate({"heat-1": {"props": {"autoBoost": {"active": True}}}}) + result = await climate.get_heat_on_demand(_make_device()) + assert result is True + + async def test_get_heat_on_demand_returns_none_when_missing(self): + """Returns None when the nested path does not exist.""" + climate = _make_climate({"heat-1": {"props": {}}}) + result = await climate.get_heat_on_demand(_make_device()) + assert result is None + + +class TestHeatingSetHeatOnDemand: + """Lines 337-342: set_heat_on_demand calls _execute_state_change with autoBoost kwarg.""" + + async def test_set_heat_on_demand_enabled(self): + """set_heat_on_demand passes autoBoost='ENABLED' to the API.""" + climate = _make_climate({"heat-1": {"type": "heating"}}) + result = await climate.set_heat_on_demand(_make_device(), "ENABLED") + assert result is True + climate.session.api.set_state.assert_called_once() + _, kwargs = climate.session.api.set_state.call_args + assert kwargs.get("autoBoost") == "ENABLED" + + async def test_set_heat_on_demand_disabled(self): + """set_heat_on_demand passes autoBoost='DISABLED' to the API.""" + climate = _make_climate({"heat-1": {"type": "heating"}}) + result = await climate.set_heat_on_demand(_make_device(), "DISABLED") + assert result is True + _, kwargs = climate.session.api.set_state.call_args + assert kwargs.get("autoBoost") == "DISABLED" + + +class TestHeatingGetScheduleNNLKeyError: + """Lines 438-439: KeyError in get_schedule_now_next_later.""" + + async def test_missing_schedule_key_returns_none(self): + """Product with state but no 'schedule' key causes KeyError → returns None.""" + climate = _make_climate( + {"heat-1": {"state": {"mode": "SCHEDULE"}}} + # no 'schedule' key inside state + ) + # Override get_mode to return SCHEDULE directly so the if-branch is entered + climate.session.helper.get_schedule_nnl.side_effect = KeyError("schedule") + # get_mode will read data["state"]["mode"] == "SCHEDULE" → enters the try block + # data["state"]["schedule"] raises KeyError → caught, returns None + result = await climate.get_schedule_now_next_later(_make_device()) + assert result is None + + async def test_schedule_key_error_caught_not_raised(self): + """A KeyError inside the try block does not propagate to the caller.""" + climate = _make_climate({"heat-1": {"state": {"mode": "SCHEDULE"}}}) + # Accessing data["state"]["schedule"] will raise KeyError (key absent) + try: + result = await climate.get_schedule_now_next_later(_make_device()) + except KeyError: + pytest.fail( + "KeyError should have been caught inside get_schedule_now_next_later" + ) + assert result is None + + +class TestHeatingSetBoostOffScheduleMode: + """Lines 321->325: prev_mode not in ('MANUAL','OFF') — target kwarg not added.""" + + async def test_schedule_mode_no_target_kwarg(self): + """SCHEDULE as previous mode does not add a target kwarg.""" + climate = _make_climate( + { + "heat-1": { + "type": "heating", + "state": {"boost": 5}, + "props": {"previous": {"mode": "SCHEDULE"}}, + } + } + ) + result = await climate.set_boost_off(_make_device()) + assert result is True + _, kwargs = climate.session.api.set_state.call_args + assert "target" not in kwargs + assert kwargs.get("mode") == "SCHEDULE" + + +class TestHeatingGetClimateCacheMiss: + """Lines 371->377: cache enabled but cached device is None → normal execution.""" + + async def test_cached_none_falls_through_to_normal_path(self): + """should_use_cached_data=True but get_cached_device=None → normal update.""" + climate = _make_climate( + { + "heat-1": { + "state": {"mode": "MANUAL", "target": 20.0}, + "props": {"temperature": 19.0}, + } + }, + devices={"dev-1": {"state": {}, "props": {}}}, + ) + climate.session.should_use_cached_data.return_value = True + climate.session.get_cached_device.return_value = None + result = await climate.get_climate(_make_device()) + assert result is not None + climate.session.attr.online_offline.assert_called_once() diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index c07ad01..d7d4638 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -476,3 +476,93 @@ def test_heating_matches_by_zone(self): } result = helper.get_device_data(product) assert result["id"] == "thermo-1" + + +# =========================================================================== +# Migrated from test_remaining_branches.py +# =========================================================================== + + +class TestHiveHelperZoneMismatch: + """hive_helper.py 163->160: loop continues when zones don't match.""" + + def test_zone_mismatch_keeps_product_as_device(self): + """When a Thermo device's zone doesn't match the product's zone, + the loop arc 163->160 is taken and device stays as the product.""" + helper, _ = _make_helper( + devices={ + "thermo-1": { + "type": "thermostatui", + "props": {"zone": "zone-B"}, + } + } + ) + + product = { + "type": "heating", + "id": "prod-1", + "props": {"zone": "zone-A"}, # different zone from thermo-1 + } + + result = helper.get_device_data(product) + # The zone mismatch means device was never re-assigned; returns the product + assert result is product + + def test_trv_without_zone_does_not_log_warning(self, caplog): + """TRV devices that omit 'zone' from props are silently skipped (no warning).""" + import logging + + helper, _ = _make_helper( + devices={ + "trv-1": { + "type": "trv", + "props": {"online": True}, # no 'zone' key — current API behaviour + } + } + ) + + product = { + "type": "heating", + "id": "prod-1", + "props": {"zone": "zone-A"}, + } + + with caplog.at_level(logging.WARNING, logger="apyhiveapi.helper.hive_helper"): + result = helper.get_device_data(product) + + assert result is product + assert not caplog.records, ( + f"Unexpected warnings: {[r.getMessage() for r in caplog.records]}" + ) + + +class TestHiveHelperSanitizeListNode: + """hive_helper.py line 359: list value under a non-sensitive key calls _walk(list).""" + + def test_list_under_non_sensitive_key_is_walked(self): + """A list value under a non-sensitive key hits the isinstance(node, list) branch.""" + helper, _ = _make_helper() + result = helper.sanitize_payload({"devices": ["device-a", "device-b"]}) + # 'devices' is not a sensitive key → _walk called for the list + # _walk for a list returns [_walk(item) for item in node] + # Each string item: _walk(str) → str (falls through to return node) + assert result == {"devices": ["device-a", "device-b"]} + + def test_list_containing_dicts_is_walked_recursively(self): + """A list of dicts under a non-sensitive key is recursively processed.""" + helper, _ = _make_helper() + result = helper.sanitize_payload( + { + "items": [ + {"token": "abc", "name": "device1"}, + {"token": "xyz", "name": "device2"}, + ] + } + ) + # 'items' is not sensitive → _walk called for the list + # Each dict in the list is processed by _walk + # 'token' IS sensitive → masked in each sub-dict + assert result["items"][0]["name"] == "device1" + assert result["items"][0]["token"] != "abc" + assert result["items"][1]["name"] == "device2" + assert result["items"][1]["token"] != "xyz" diff --git a/tests/unit/test_hotwater_extended.py b/tests/unit/test_hotwater_extended.py index ddf3d19..0696510 100644 --- a/tests/unit/test_hotwater_extended.py +++ b/tests/unit/test_hotwater_extended.py @@ -258,3 +258,72 @@ class StubHotwater(HiveHotwater): ) result = await h.get_state(d) assert result is None or isinstance(result, str) + + +# =========================================================================== +# Migrated from test_remaining_branches.py +# =========================================================================== + + +class TestHotwaterGetModeKeyError: + """Lines 43-44: KeyError in get_mode.""" + + async def test_get_mode_missing_state_returns_none(self): + """Product with no 'state' key causes KeyError → final stays None.""" + hw = _make_hotwater({"hw-1": {}}) + result = await hw.get_mode(_make_device()) + assert result is None + + +class TestHotwaterGetStateKeyError: + """Lines 83-84: KeyError in get_state.""" + + async def test_get_state_missing_status_key_returns_none(self): + """Product 'state' dict missing 'status' key triggers KeyError → None.""" + hw = _make_hotwater({"hw-1": {"state": {"mode": "MANUAL"}}}) + # 'status' key is absent from state → KeyError on data["state"]["status"] + result = await hw.get_state(_make_device()) + assert result is None + + async def test_get_state_missing_schedule_in_schedule_mode_returns_none(self): + """SCHEDULE mode with no 'schedule' key in state causes KeyError → None.""" + hw = _make_hotwater( + { + "hw-1": { + "state": { + "mode": "SCHEDULE", + "status": "ON", + "boost": False, + # no 'schedule' key + } + } + } + ) + result = await hw.get_state(_make_device()) + assert result is None + + +class TestHotwaterScheduleNNLNone: + """Lines 225->227: get_schedule_now_next_later returns None when schedule is absent.""" + + async def test_schedule_none_when_no_schedule_in_state(self): + """SCHEDULE mode product without 'schedule' key → _get_product_state returns None → None.""" + hw = _make_hotwater({"hw-1": {"state": {"mode": "SCHEDULE"}}}) + # _get_product_state(device, "state", "schedule") → None (key absent) + result = await hw.get_schedule_now_next_later(_make_device()) + assert result is None + + +class TestHotwaterGetWaterHeaterCacheMiss: + """Lines 173->180: cache enabled but cached is None → continues with network call.""" + + async def test_cached_none_falls_through(self): + hw = _make_hotwater( + {"hw-1": {"state": {"mode": "ON"}, "props": {}}}, + devices={"dev-1": {"state": {}, "props": {}}}, + ) + hw.session.should_use_cached_data.return_value = True + hw.session.get_cached_device.return_value = None + result = await hw.get_water_heater(_make_device()) + assert result is not None + hw.session.attr.online_offline.assert_called_once() diff --git a/tests/unit/test_light_extended.py b/tests/unit/test_light_extended.py index d3f2810..6ad1a6f 100644 --- a/tests/unit/test_light_extended.py +++ b/tests/unit/test_light_extended.py @@ -268,3 +268,27 @@ class StubLight(HiveLight): f"Expected int, got {type(result).__name__}: {result!r}" ) assert result == 127 + + +# =========================================================================== +# Migrated from test_remaining_branches.py +# =========================================================================== + + +class TestLightGetLightCacheMiss: + """Lines 141->147: cache enabled but cached is None → normal execution.""" + + async def test_cached_none_falls_through(self): + session = _make_session( + products={ + "light-1": {"state": {"status": "ON", "brightness": 100}, "props": {}} + }, + devices={"dev-1": {"state": {}, "props": {}}}, + ) + light = Light(session=session) + d = _make_device() + session.should_use_cached_data.return_value = True + session.get_cached_device.return_value = None + result = await light.get_light(d) + assert result is not None + session.attr.online_offline.assert_called_once() diff --git a/tests/unit/test_remaining_branches.py b/tests/unit/test_remaining_branches.py deleted file mode 100644 index 14493c1..0000000 --- a/tests/unit/test_remaining_branches.py +++ /dev/null @@ -1,1067 +0,0 @@ -"""Branch-coverage tests for several source modules. - -Covers missing lines in: - - src/devices/heating.py - - src/devices/hotwater.py - - src/devices/light.py - - src/devices/sensor.py - - src/session/auth.py - - src/session/discovery.py -""" - -# pylint: disable=too-few-public-methods,protected-access,attribute-defined-outside-init - -import asyncio -from datetime import datetime, timedelta -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from apyhiveapi.devices.heating import Climate -from apyhiveapi.devices.hotwater import WaterHeater -from apyhiveapi.devices.light import Light -from apyhiveapi.devices.sensor import Sensor -from apyhiveapi.helper.hive_exceptions import HiveApiError -from apyhiveapi.helper.hive_helper import HiveHelper -from apyhiveapi.helper.hivedataclasses import ( - Device, - EntityConfig, - SessionConfig, - SessionTokens, -) -from apyhiveapi.helper.map import Map -from apyhiveapi.session.auth import SessionAuthMixin -from apyhiveapi.session.discovery import DiscoveryMixin - -# --------------------------------------------------------------------------- -# Shared helpers — heating -# --------------------------------------------------------------------------- - - -def _make_climate(products=None, devices=None, min_max=None): - session = MagicMock() - session.data = Map( - { - "products": products or {}, - "devices": devices or {}, - "actions": {}, - "minMax": min_max or {}, - "user": {}, - } - ) - session.config = SessionConfig() - session.helper = MagicMock() - session.helper.device_recovered = MagicMock() - session.helper.error_check = AsyncMock() - session.helper.get_schedule_nnl = MagicMock( - return_value={"now": {}, "next": {}, "later": {}} - ) - session.attr = MagicMock() - session.attr.online_offline = AsyncMock(return_value=True) - session.attr.state_attributes = AsyncMock(return_value={}) - session.api = MagicMock() - session.api.set_state = AsyncMock(return_value={"original": 200, "parsed": {}}) - session.hive_refresh_tokens = AsyncMock() - session.get_devices = AsyncMock(return_value=True) - session.should_use_cached_data = MagicMock(return_value=False) - session.get_cached_device = MagicMock(return_value=None) - session.set_cached_device = MagicMock(side_effect=lambda d: d) - return Climate(session=session) - - -def _make_device(hive_id="heat-1", device_id="dev-1", hive_type="heating"): - return Device( - hive_id=hive_id, - hive_name="Hallway", - hive_type=hive_type, - ha_type="climate", - device_id=device_id, - device_name="Hallway", - device_data={"online": True}, - ha_name="Hallway", - ) - - -# --------------------------------------------------------------------------- -# Shared helpers — hotwater -# --------------------------------------------------------------------------- - - -def _make_hotwater(products=None, devices=None): - session = MagicMock() - session.data = Map( - { - "products": products or {}, - "devices": devices or {}, - "actions": {}, - "minMax": {}, - "user": {}, - } - ) - session.config = SessionConfig() - session.helper = MagicMock() - session.helper.device_recovered = MagicMock() - session.helper.get_schedule_nnl = MagicMock( - return_value={"now": {}, "next": {}, "later": {}} - ) - session.attr = MagicMock() - session.attr.online_offline = AsyncMock(return_value=True) - session.attr.state_attributes = AsyncMock(return_value={}) - session.api = MagicMock() - session.api.set_state = AsyncMock(return_value={"original": 200, "parsed": {}}) - session.hive_refresh_tokens = AsyncMock() - session.get_devices = AsyncMock(return_value=True) - session.should_use_cached_data = MagicMock(return_value=False) - session.get_cached_device = MagicMock(return_value=None) - session.set_cached_device = MagicMock(side_effect=lambda d: d) - return WaterHeater(session=session) - - -def _make_hw_device(hive_id="hw-1", device_id="dev-1"): - return Device( - hive_id=hive_id, - hive_name="Hot Water", - hive_type="hotwater", - ha_type="water_heater", - device_id=device_id, - device_name="Hot Water", - device_data={"online": True}, - ha_name="Hot Water", - ) - - -# --------------------------------------------------------------------------- -# Shared helpers — sensor -# --------------------------------------------------------------------------- - - -def _make_sensor(products=None, devices=None): - session = MagicMock() - session.data = Map( - { - "products": products or {}, - "devices": devices or {}, - "actions": {}, - "minMax": {}, - "user": {}, - } - ) - session.config = SessionConfig() - session.helper = MagicMock() - session.helper.device_recovered = MagicMock() - session.attr = MagicMock() - session.attr.online_offline = AsyncMock(return_value=True) - session.attr.state_attributes = AsyncMock(return_value={}) - session.should_use_cached_data = MagicMock(return_value=False) - session.get_cached_device = MagicMock(return_value=None) - session.set_cached_device = MagicMock(side_effect=lambda d: d) - return Sensor(session=session) - - -def _make_sensor_device(hive_id="sens-1", device_id="dev-1", hive_type="contactsensor"): - return Device( - hive_id=hive_id, - hive_name="Door", - hive_type=hive_type, - ha_type="binary_sensor", - device_id=device_id, - device_name="Door", - device_data={"online": True}, - ha_name="Door", - ) - - -# --------------------------------------------------------------------------- -# Shared helpers — auth -# --------------------------------------------------------------------------- - - -def _make_auth_stub(): - class StubAuth(SessionAuthMixin): - """Concrete subclass used only for testing.""" - - s = StubAuth() - s.auth = MagicMock() - s.auth.DEVICE_VERIFIER_CHALLENGE = "DEVICE_SRP_AUTH" - s.auth.SMS_MFA_CHALLENGE = "SMS_MFA" - s.auth.login = AsyncMock() - s.auth.device_login = AsyncMock() - s.auth.sms_2fa = AsyncMock() - s.auth.refresh_token = AsyncMock() - s.tokens = SessionTokens() - s.tokens.token_data = {"refreshToken": "rt", "token": "", "accessToken": ""} - s.config = SessionConfig() - s.helper = MagicMock() - s.helper.sanitize_payload = MagicMock(return_value={}) - s._refresh_threshold = 0.90 - s._refresh_lock = asyncio.Lock() - return s - - -# --------------------------------------------------------------------------- -# Shared helpers — discovery -# --------------------------------------------------------------------------- - - -def _make_discovery_stub(products=None, devices=None, actions=None): - class StubDiscovery(DiscoveryMixin): - """Concrete subclass used only for testing.""" - - s = StubDiscovery() - s.config = SessionConfig() - s.data = Map( - { - "products": products or {}, - "devices": devices or {}, - "actions": actions or {}, - "user": {"temperatureUnit": "C"}, - "minMax": {}, - } - ) - s.helper = MagicMock() - s.helper.get_device_data = MagicMock( - return_value={ - "id": "dev-1", - "state": {"name": "Test Device"}, - "props": {"online": True}, - } - ) - s.hub_id = None - s.device_list = { - "parent": [], - "binary_sensor": [], - "climate": [], - "light": [], - "sensor": [], - "switch": [], - "water_heater": [], - } - return s - - -# =========================================================================== -# 1. src/devices/heating.py -# =========================================================================== - - -class TestHeatingGetStateKeyError: - """Lines 206-207: KeyError/TypeError branch in get_state.""" - - async def test_get_state_key_error_returns_none(self): - """Missing product entry causes get_current_temperature to return None, - leaving final as None without raising.""" - # products dict is empty — device.hive_id not found → both temp helpers - # return None → the if branch is skipped → final stays None - climate = _make_climate(products={}) - d = _make_device() - result = await climate.get_state(d) - assert result is None - - -class TestHeatingGetHeatOnDemand: - """Line 231: get_heat_on_demand happy path.""" - - async def test_get_heat_on_demand_returns_value(self): - """Returns the nested autoBoost.active value from products.""" - climate = _make_climate({"heat-1": {"props": {"autoBoost": {"active": True}}}}) - result = await climate.get_heat_on_demand(_make_device()) - assert result is True - - async def test_get_heat_on_demand_returns_none_when_missing(self): - """Returns None when the nested path does not exist.""" - climate = _make_climate({"heat-1": {"props": {}}}) - result = await climate.get_heat_on_demand(_make_device()) - assert result is None - - -class TestHeatingSetHeatOnDemand: - """Lines 337-342: set_heat_on_demand calls _execute_state_change with autoBoost kwarg.""" - - async def test_set_heat_on_demand_enabled(self): - """set_heat_on_demand passes autoBoost='ENABLED' to the API.""" - climate = _make_climate({"heat-1": {"type": "heating"}}) - result = await climate.set_heat_on_demand(_make_device(), "ENABLED") - assert result is True - climate.session.api.set_state.assert_called_once() - _, kwargs = climate.session.api.set_state.call_args - assert kwargs.get("autoBoost") == "ENABLED" - - async def test_set_heat_on_demand_disabled(self): - """set_heat_on_demand passes autoBoost='DISABLED' to the API.""" - climate = _make_climate({"heat-1": {"type": "heating"}}) - result = await climate.set_heat_on_demand(_make_device(), "DISABLED") - assert result is True - _, kwargs = climate.session.api.set_state.call_args - assert kwargs.get("autoBoost") == "DISABLED" - - -class TestHeatingGetScheduleNNLKeyError: - """Lines 438-439: KeyError in get_schedule_now_next_later.""" - - async def test_missing_schedule_key_returns_none(self): - """Product with state but no 'schedule' key causes KeyError → returns None.""" - climate = _make_climate( - {"heat-1": {"state": {"mode": "SCHEDULE"}}} - # no 'schedule' key inside state - ) - # Override get_mode to return SCHEDULE directly so the if-branch is entered - climate.session.helper.get_schedule_nnl.side_effect = KeyError("schedule") - # get_mode will read data["state"]["mode"] == "SCHEDULE" → enters the try block - # data["state"]["schedule"] raises KeyError → caught, returns None - result = await climate.get_schedule_now_next_later(_make_device()) - assert result is None - - async def test_schedule_key_error_caught_not_raised(self): - """A KeyError inside the try block does not propagate to the caller.""" - climate = _make_climate({"heat-1": {"state": {"mode": "SCHEDULE"}}}) - # Accessing data["state"]["schedule"] will raise KeyError (key absent) - try: - result = await climate.get_schedule_now_next_later(_make_device()) - except KeyError: - pytest.fail( - "KeyError should have been caught inside get_schedule_now_next_later" - ) - assert result is None - - -# =========================================================================== -# 2. src/devices/hotwater.py -# =========================================================================== - - -class TestHotwaterGetModeKeyError: - """Lines 43-44: KeyError in get_mode.""" - - async def test_get_mode_missing_state_returns_none(self): - """Product with no 'state' key causes KeyError → final stays None.""" - hw = _make_hotwater({"hw-1": {}}) - result = await hw.get_mode(_make_hw_device()) - assert result is None - - -class TestHotwaterGetStateKeyError: - """Lines 83-84: KeyError in get_state.""" - - async def test_get_state_missing_status_key_returns_none(self): - """Product 'state' dict missing 'status' key triggers KeyError → None.""" - hw = _make_hotwater({"hw-1": {"state": {"mode": "MANUAL"}}}) - # 'status' key is absent from state → KeyError on data["state"]["status"] - result = await hw.get_state(_make_hw_device()) - assert result is None - - async def test_get_state_missing_schedule_in_schedule_mode_returns_none(self): - """SCHEDULE mode with no 'schedule' key in state causes KeyError → None.""" - hw = _make_hotwater( - { - "hw-1": { - "state": { - "mode": "SCHEDULE", - "status": "ON", - "boost": False, - # no 'schedule' key - } - } - } - ) - result = await hw.get_state(_make_hw_device()) - assert result is None - - -class TestHotwaterScheduleNNLNone: - """Lines 225->227: get_schedule_now_next_later returns None when schedule is absent.""" - - async def test_schedule_none_when_no_schedule_in_state(self): - """SCHEDULE mode product without 'schedule' key → _get_product_state returns None → None.""" - hw = _make_hotwater({"hw-1": {"state": {"mode": "SCHEDULE"}}}) - # _get_product_state(device, "state", "schedule") → None (key absent) - result = await hw.get_schedule_now_next_later(_make_hw_device()) - assert result is None - - -# =========================================================================== -# 3. src/devices/sensor.py -# =========================================================================== - - -class TestSensorGetStateKeyError: - """Lines 37->42: KeyError in HiveSensor.get_state.""" - - async def test_get_state_missing_type_key_returns_none(self): - """Product with no 'type' key causes KeyError → final stays None.""" - sensor = _make_sensor({"sens-1": {}}) - d = _make_sensor_device() - result = await sensor.get_state(d) - assert result is None - - async def test_get_state_missing_props_key_returns_none(self): - """contactsensor product without 'props' causes KeyError → None.""" - sensor = _make_sensor({"sens-1": {"type": "contactsensor"}}) - d = _make_sensor_device() - result = await sensor.get_state(d) - assert result is None - - -# =========================================================================== -# 4. src/session/auth.py -# =========================================================================== - - -class TestRetryWithBackoffNonZeroDelay: - """Line 66: asyncio.sleep called when delay > 0.""" - - async def test_non_zero_delay_is_awaited_but_succeeds(self): - """A non-zero delay entry causes asyncio.sleep to be called; factory still runs.""" - s = _make_auth_stub() - calls = [] - - async def factory(): - calls.append(1) - return "ok" - - with patch( - "apyhiveapi.session.auth.asyncio.sleep", new_callable=AsyncMock - ) as mock_sleep: - result = await s._retry_with_backoff(factory, delays=(5,)) - assert result == "ok" - mock_sleep.assert_called_once_with(5) - assert len(calls) == 1 - - async def test_zero_delay_does_not_call_sleep(self): - """A zero delay skips asyncio.sleep.""" - s = _make_auth_stub() - - async def factory(): - return "done" - - with patch( - "apyhiveapi.session.auth.asyncio.sleep", new_callable=AsyncMock - ) as mock_sleep: - result = await s._retry_with_backoff(factory, delays=(0,)) - assert result == "done" - mock_sleep.assert_not_called() - - -class TestUpdateTokensFlatDictWithExpiresIn: - """Lines 100->106: flat token dict with ExpiresIn sets token_expiry.""" - - async def test_flat_dict_with_expires_in_sets_token_expiry(self): - """Flat token dict containing ExpiresIn updates tokens.token_expiry.""" - s = _make_auth_stub() - flat = { - "token": "t", - "refreshToken": "r", - "accessToken": "a", - "ExpiresIn": 1800, - } - await s.update_tokens(flat) - assert s.tokens.token_expiry == timedelta(seconds=1800) - - async def test_flat_dict_tokens_are_stored(self): - """All token values from flat dict are written to token_data.""" - s = _make_auth_stub() - flat = {"token": "my-id", "refreshToken": "my-rt", "accessToken": "my-at"} - await s.update_tokens(flat) - assert s.tokens.token_data["token"] == "my-id" - assert s.tokens.token_data["refreshToken"] == "my-rt" - assert s.tokens.token_data["accessToken"] == "my-at" - - -class TestLoginApiError: - """Lines 160-162: HiveApiError in login() is logged and re-raised.""" - - async def test_login_api_error_reraises(self): - """HiveApiError raised by auth.login propagates unchanged to the caller.""" - s = _make_auth_stub() - s.auth.login.side_effect = HiveApiError() - with pytest.raises(HiveApiError): - await s.login() - - -class TestHiveRefreshTokensNoAuthResult: - """Lines 341->373: refresh returns a result but without AuthenticationResult.""" - - async def test_result_without_auth_result_does_not_update_tokens(self): - """When refresh_token returns a dict with no AuthenticationResult, tokens stay unchanged.""" - s = _make_auth_stub() - s.tokens.token_created = datetime.now() - timedelta(hours=2) - s.tokens.token_expiry = timedelta(hours=1) - # Return something truthy but without AuthenticationResult - s.auth.refresh_token.return_value = {"SomeOtherKey": "value"} - result = await s.hive_refresh_tokens() - # Tokens must not have been updated - assert s.tokens.token_data["token"] == "" - assert s.tokens.token_data["accessToken"] == "" - # result is what refresh_token returned - assert result == {"SomeOtherKey": "value"} - - async def test_none_refresh_result_does_not_update_tokens(self): - """When refresh_token returns None, tokens are left unchanged.""" - s = _make_auth_stub() - s.tokens.token_created = datetime.now() - timedelta(hours=2) - s.tokens.token_expiry = timedelta(hours=1) - s.auth.refresh_token.return_value = None - await s.hive_refresh_tokens() - assert s.tokens.token_data["token"] == "" - - -# =========================================================================== -# 5. src/session/discovery.py -# =========================================================================== - - -class TestCreateDevicesEntityConfigKwargs: - """Lines 224->226, 226->228, 228->230: entity_config kwarg population in DEVICES loop.""" - - async def test_entity_config_with_all_fields_populates_kwargs(self): - """EntityConfig with ha_name, hive_type, and category all set → all kwargs passed.""" - s = _make_discovery_stub( - devices={ - "dev-1": { - "id": "dev-1", - "type": "hub", - "state": {"name": "My Hub"}, - "props": {}, - } - } - ) - entity_cfg = EntityConfig( - entity_type="binary_sensor", - ha_name="Hub Status", - hive_type="Connectivity", - category="diagnostic", - ) - with patch("apyhiveapi.session.discovery.DEVICES", {"hub": [entity_cfg]}): - result = await s.create_devices() - assert len(result["binary_sensor"]) == 1 - created = result["binary_sensor"][0] - assert created.hive_type == "Connectivity" - assert created.category == "diagnostic" - - async def test_entity_config_empty_fields_does_not_add_to_kwargs(self): - """EntityConfig with empty ha_name and hive_type does not inject those keys.""" - s = _make_discovery_stub( - devices={ - "dev-1": { - "id": "dev-1", - "type": "hub", - "state": {"name": "My Hub"}, - "props": {}, - } - } - ) - entity_cfg = EntityConfig( - entity_type="binary_sensor", - ha_name="", # falsy — should not be added to kwargs - hive_type="", # falsy — should not be added to kwargs - category=None, # None — should not be added to kwargs - ) - with patch("apyhiveapi.session.discovery.DEVICES", {"hub": [entity_cfg]}): - result = await s.create_devices() - # Should still process without error - assert isinstance(result, dict) - - -class TestCreateDevicesDeviceAddListError: - """Lines 232-233: KeyError/TypeError from add_list in DEVICES loop is caught.""" - - async def test_add_list_keyerror_is_caught_not_raised(self): - """KeyError from add_list during device processing is logged, not propagated.""" - s = _make_discovery_stub( - devices={ - "dev-1": { - "id": "dev-1", - "type": "hub", - "state": {"name": "My Hub"}, - "props": {}, - } - } - ) - entity_cfg = EntityConfig( - entity_type="binary_sensor", - ha_name="Hub Status", - hive_type="Connectivity", - category="diagnostic", - ) - with patch("apyhiveapi.session.discovery.DEVICES", {"hub": [entity_cfg]}): - with patch.object(s, "add_list", side_effect=KeyError("bad key")): - # Should complete without raising - result = await s.create_devices() - assert isinstance(result, dict) - - async def test_add_list_typeerror_is_caught_not_raised(self): - """TypeError from add_list during device processing is caught.""" - s = _make_discovery_stub( - devices={ - "dev-1": { - "id": "dev-1", - "type": "hub", - "state": {"name": "My Hub"}, - "props": {}, - } - } - ) - entity_cfg = EntityConfig( - entity_type="binary_sensor", - ha_name="", - hive_type="", - category=None, - ) - with patch("apyhiveapi.session.discovery.DEVICES", {"hub": [entity_cfg]}): - with patch.object(s, "add_list", side_effect=TypeError("bad type")): - result = await s.create_devices() - assert isinstance(result, dict) - - -class TestCreateDevicesActionAddListError: - """Lines 258-259: KeyError/TypeError from add_list in actions loop is caught.""" - - async def test_action_add_list_keyerror_is_caught(self): - """KeyError from add_list when processing an action is logged, not propagated.""" - s = _make_discovery_stub( - actions={"act-1": {"id": "act-1", "name": "Good Night"}} - ) - with patch.object(s, "add_list", side_effect=KeyError("missing")): - result = await s.create_devices() - assert isinstance(result, dict) - - async def test_action_add_list_typeerror_is_caught(self): - """TypeError from add_list when processing an action is caught.""" - s = _make_discovery_stub(actions={"act-1": {"id": "act-1", "name": "Wake Up"}}) - with patch.object(s, "add_list", side_effect=TypeError("type error")): - result = await s.create_devices() - assert isinstance(result, dict) - - -class TestCreateDevicesProductTemperatureUnit: - """Line 305: entity_config.temperature_unit is used when set and entity_type != 'climate'.""" - - async def test_entity_config_temperature_unit_passed_to_add_list(self): - """EntityConfig with temperature_unit set propagates that value as a kwarg.""" - s = _make_discovery_stub( - products={ - "prod-1": { - "id": "prod-1", - "type": "heating", - "state": {"name": "Heating"}, - "props": {}, - } - } - ) - # A non-climate entity with temperature_unit set triggers line 305 - entity_cfg = EntityConfig( - entity_type="sensor", - ha_name="Temp Sensor", - hive_type="Current_Temperature", - category="diagnostic", - temperature_unit="F", - ) - captured_kwargs = {} - - original_add_list = s.add_list - - def capturing_add_list(entity_type, data, **kwargs): - captured_kwargs.update(kwargs) - return original_add_list(entity_type, data, **kwargs) - - with patch("apyhiveapi.session.discovery.PRODUCTS", {"heating": [entity_cfg]}): - with patch.object(s, "add_list", side_effect=capturing_add_list): - await s.create_devices() - - assert captured_kwargs.get("temperature_unit") == "F" - - -class TestCreateDevicesProductAddListAttributeError: - """Lines 308-309: NameError/AttributeError from add_list in products loop is caught.""" - - async def test_product_add_list_attribute_error_is_caught(self): - """AttributeError from add_list when processing a product is caught.""" - s = _make_discovery_stub( - products={ - "prod-1": { - "id": "prod-1", - "type": "heating", - "state": {"name": "Heating"}, - "props": {}, - } - } - ) - entity_cfg = EntityConfig( - entity_type="climate", - ha_name="", - hive_type="", - category=None, - ) - with patch("apyhiveapi.session.discovery.PRODUCTS", {"heating": [entity_cfg]}): - with patch.object(s, "add_list", side_effect=AttributeError("attr error")): - result = await s.create_devices() - assert isinstance(result, dict) - - async def test_product_add_list_name_error_is_caught(self): - """NameError from add_list when processing a product is caught.""" - s = _make_discovery_stub( - products={ - "prod-1": { - "id": "prod-1", - "type": "heating", - "state": {"name": "Heating"}, - "props": {}, - } - } - ) - entity_cfg = EntityConfig( - entity_type="climate", - ha_name="", - hive_type="", - category=None, - ) - with patch("apyhiveapi.session.discovery.PRODUCTS", {"heating": [entity_cfg]}): - with patch.object(s, "add_list", side_effect=NameError("name error")): - result = await s.create_devices() - assert isinstance(result, dict) - - -# =========================================================================== -# Additional False-branch tests: cache-miss paths and elif False paths -# =========================================================================== - - -def _make_light_session(products=None, devices=None): - session = MagicMock() - session.data = Map( - { - "products": products or {}, - "devices": devices or {}, - "actions": {}, - "minMax": {}, - "user": {}, - } - ) - session.config = SessionConfig() - session.helper = MagicMock() - session.helper.device_recovered = MagicMock() - session.helper.error_check = AsyncMock() - session.attr = MagicMock() - session.attr.online_offline = AsyncMock(return_value=True) - session.attr.state_attributes = AsyncMock(return_value={}) - session.api = MagicMock() - session.api.set_state = AsyncMock(return_value={"original": 200, "parsed": {}}) - session.hive_refresh_tokens = AsyncMock() - session.get_devices = AsyncMock(return_value=True) - session.should_use_cached_data = MagicMock(return_value=False) - session.get_cached_device = MagicMock(return_value=None) - session.set_cached_device = MagicMock(side_effect=lambda d: d) - return session - - -def _make_light_device( - hive_id="light-1", device_id="dev-1", hive_type="warmwhitelight" -): - return Device( - hive_id=hive_id, - hive_name="Bulb", - hive_type=hive_type, - ha_type="light", - device_id=device_id, - device_name="Bulb", - device_data={"online": True}, - ha_name="Bulb", - ) - - -# --------------------------------------------------------------------------- -# heating.py: 321->325 — set_boost_off with non-MANUAL/OFF previous mode -# --------------------------------------------------------------------------- - - -class TestHeatingSetBoostOffScheduleMode: - """Lines 321->325: prev_mode not in ('MANUAL','OFF') — target kwarg not added.""" - - async def test_schedule_mode_no_target_kwarg(self): - """SCHEDULE as previous mode does not add a target kwarg.""" - climate = _make_climate( - { - "heat-1": { - "type": "heating", - "state": {"boost": 5}, - "props": {"previous": {"mode": "SCHEDULE"}}, - } - } - ) - result = await climate.set_boost_off(_make_device()) - assert result is True - _, kwargs = climate.session.api.set_state.call_args - assert "target" not in kwargs - assert kwargs.get("mode") == "SCHEDULE" - - -# --------------------------------------------------------------------------- -# heating.py: 371->377 — get_climate: should_use_cached=True but cached is None -# --------------------------------------------------------------------------- - - -class TestHeatingGetClimateCacheMiss: - """Lines 371->377: cache enabled but cached device is None → normal execution.""" - - async def test_cached_none_falls_through_to_normal_path(self): - """should_use_cached_data=True but get_cached_device=None → normal update.""" - climate = _make_climate( - { - "heat-1": { - "state": {"mode": "MANUAL", "target": 20.0}, - "props": {"temperature": 19.0}, - } - }, - devices={"dev-1": {"state": {}, "props": {}}}, - ) - climate.session.should_use_cached_data.return_value = True - climate.session.get_cached_device.return_value = None - result = await climate.get_climate(_make_device()) - assert result is not None - climate.session.attr.online_offline.assert_called_once() - - -# --------------------------------------------------------------------------- -# hotwater.py: 173->180 — same pattern -# --------------------------------------------------------------------------- - - -class TestHotwaterGetWaterHeaterCacheMiss: - """Lines 173->180: cache enabled but cached is None → continues with network call.""" - - async def test_cached_none_falls_through(self): - hw = _make_hotwater( - {"hw-1": {"state": {"mode": "ON"}, "props": {}}}, - devices={"dev-1": {"state": {}, "props": {}}}, - ) - hw.session.should_use_cached_data.return_value = True - hw.session.get_cached_device.return_value = None - result = await hw.get_water_heater(_make_hw_device()) - assert result is not None - hw.session.attr.online_offline.assert_called_once() - - -# --------------------------------------------------------------------------- -# light.py: 141->147 — same pattern -# --------------------------------------------------------------------------- - - -class TestLightGetLightCacheMiss: - """Lines 141->147: cache enabled but cached is None → normal execution.""" - - async def test_cached_none_falls_through(self): - session = _make_light_session( - products={ - "light-1": {"state": {"status": "ON", "brightness": 100}, "props": {}} - }, - devices={"dev-1": {"state": {}, "props": {}}}, - ) - light = Light(session=session) - d = _make_light_device() - session.should_use_cached_data.return_value = True - session.get_cached_device.return_value = None - result = await light.get_light(d) - assert result is not None - session.attr.online_offline.assert_called_once() - - -# --------------------------------------------------------------------------- -# sensor.py: 37->42 — get_state: type neither contactsensor nor motionsensor -# --------------------------------------------------------------------------- - - -class TestSensorGetStateUnknownType: - """Lines 37->42: data['type'] is neither contactsensor nor motionsensor.""" - - async def test_unknown_type_returns_none(self): - """Product with type 'hub' skips both if/elif → final stays None.""" - sensor = _make_sensor({"sens-1": {"type": "hub", "props": {}}}) - d = _make_sensor_device() - result = await sensor.get_state(d) - assert result is None - - -# --------------------------------------------------------------------------- -# sensor.py: 92->98 — get_sensor: cache enabled but cached is None -# --------------------------------------------------------------------------- - - -class TestSensorGetSensorCacheMiss: - """Lines 92->98: should_use_cached_data=True but cached is None.""" - - async def test_cached_none_falls_through(self): - sensor = _make_sensor( - products={"sens-1": {"type": "contactsensor", "props": {"status": "OPEN"}}}, - devices={"dev-1": {"props": {"online": True}, "type": "contactsensor"}}, - ) - sensor.session.should_use_cached_data.return_value = True - sensor.session.get_cached_device.return_value = None - d = _make_sensor_device() - result = await sensor.get_sensor(d) - assert result is not None - sensor.session.attr.online_offline.assert_called_once() - - -# --------------------------------------------------------------------------- -# sensor.py: 119->122 — get_sensor: neither device_id nor hive_id found -# --------------------------------------------------------------------------- - - -class TestSensorGetSensorNoDataFallthrough: - """Lines 119->122: device_id not in devices AND hive_id not in products.""" - - async def test_neither_match_continues_with_empty_data(self): - """data stays empty dict when neither lookup succeeds.""" - sensor = _make_sensor(products={}, devices={}) - d = _make_sensor_device( - hive_id="unknown-hive", device_id="unknown-dev", hive_type="contactsensor" - ) - result = await sensor.get_sensor(d) - # Should not raise; result will be the device (set_cached_device returns it) - assert result is not None - - -# --------------------------------------------------------------------------- -# sensor.py: 135->146 — get_sensor: hive_type not in sensor_commands or HIVE_TYPES["Sensor"] -# --------------------------------------------------------------------------- - - -class TestSensorGetSensorUnknownHiveType: - """Lines 135->146: hive_type not in sensor_commands and not in HIVE_TYPES['Sensor'].""" - - async def test_hive_type_not_in_either_dict_skips_both_branches(self): - """activeplug is neither in sensor_commands nor HIVE_TYPES['Sensor'].""" - sensor = _make_sensor( - devices={"dev-1": {"props": {"online": True}, "type": "activeplug"}} - ) - d = _make_sensor_device( - hive_id="dev-1", device_id="dev-1", hive_type="activeplug" - ) - d.device_data = {"online": True} - result = await sensor.get_sensor(d) - # Neither branch sets device.status; device returned as-is via set_cached_device - assert result is not None - - -# =========================================================================== -# Additional branches: session/auth.py, hive_helper.py, heating.py -# =========================================================================== - - -class TestUpdateTokensUnknownKey: - """session/auth.py 100->106: tokens dict has neither AuthenticationResult nor token.""" - - async def test_unknown_key_does_not_raise_and_does_not_update_tokens(self): - """When neither expected key is present, data stays {}, ExpiresIn check skips.""" - s = _make_auth_stub() - original_token = s.tokens.token_data["token"] - # Pass a dict that is neither the AuthResult form nor the flat-token form - await s.update_tokens({"some_other_key": "some_value"}) - # Tokens must be unchanged - assert s.tokens.token_data["token"] == original_token - - async def test_unknown_key_does_not_set_token_expiry(self): - """ExpiresIn check at line 106 skips when data is {} (no match in either branch).""" - s = _make_auth_stub() - original_expiry = s.tokens.token_expiry - await s.update_tokens({"random_key": "random_value"}) - assert s.tokens.token_expiry == original_expiry - - -class TestHiveHelperZoneMismatch: - """hive_helper.py 163->160: loop continues when zones don't match.""" - - def test_zone_mismatch_keeps_product_as_device(self): - """When a Thermo device's zone doesn't match the product's zone, - the loop arc 163->160 is taken and device stays as the product.""" - helper = HiveHelper(session=MagicMock()) - helper.session.data = Map( - { - "devices": { - "thermo-1": { - "type": "thermostatui", - "props": {"zone": "zone-B"}, - } - }, - "products": {}, - "actions": {}, - "user": {}, - "minMax": {}, - } - ) - - product = { - "type": "heating", - "id": "prod-1", - "props": {"zone": "zone-A"}, # different zone from thermo-1 - } - - result = helper.get_device_data(product) - # The zone mismatch means device was never re-assigned; returns the product - assert result is product - - def test_trv_without_zone_does_not_log_warning(self, caplog): - """TRV devices that omit 'zone' from props are silently skipped (no warning).""" - import logging - - helper = HiveHelper(session=MagicMock()) - helper.session.data = Map( - { - "devices": { - "trv-1": { - "type": "trv", - "props": { - "online": True - }, # no 'zone' key — current API behaviour - } - }, - "products": {}, - "actions": {}, - "user": {}, - "minMax": {}, - } - ) - - product = { - "type": "heating", - "id": "prod-1", - "props": {"zone": "zone-A"}, - } - - with caplog.at_level(logging.WARNING, logger="apyhiveapi.helper.hive_helper"): - result = helper.get_device_data(product) - - assert result is product - assert not caplog.records, ( - f"Unexpected warnings: {[r.getMessage() for r in caplog.records]}" - ) - - -class TestHiveHelperSanitizeListNode: - """hive_helper.py line 359: list value under a non-sensitive key calls _walk(list).""" - - def test_list_under_non_sensitive_key_is_walked(self): - """A list value under a non-sensitive key hits the isinstance(node, list) branch.""" - helper = HiveHelper() - result = helper.sanitize_payload({"devices": ["device-a", "device-b"]}) - # 'devices' is not a sensitive key → _walk called for the list - # _walk for a list returns [_walk(item) for item in node] - # Each string item: _walk(str) → str (falls through to return node) - assert result == {"devices": ["device-a", "device-b"]} - - def test_list_containing_dicts_is_walked_recursively(self): - """A list of dicts under a non-sensitive key is recursively processed.""" - helper = HiveHelper() - result = helper.sanitize_payload( - { - "items": [ - {"token": "abc", "name": "device1"}, - {"token": "xyz", "name": "device2"}, - ] - } - ) - # 'items' is not sensitive → _walk called for the list - # Each dict in the list is processed by _walk - # 'token' IS sensitive → masked in each sub-dict - assert result["items"][0]["name"] == "device1" - assert result["items"][0]["token"] != "abc" - assert result["items"][1]["name"] == "device2" - assert result["items"][1]["token"] != "xyz" diff --git a/tests/unit/test_sensor_extended.py b/tests/unit/test_sensor_extended.py index 604c785..3bad1b1 100644 --- a/tests/unit/test_sensor_extended.py +++ b/tests/unit/test_sensor_extended.py @@ -203,3 +203,90 @@ async def test_motionsensor_returns_motion_status(self): state = await sensor.get_state(device) assert state is True + + +# =========================================================================== +# Migrated from test_remaining_branches.py +# =========================================================================== + + +class TestSensorGetStateKeyError: + """Lines 37->42: KeyError in HiveSensor.get_state.""" + + async def test_get_state_missing_type_key_returns_none(self): + """Product with no 'type' key causes KeyError → final stays None.""" + session = _make_session({"sens-1": {}}) + sensor = Sensor(session=session) + d = _make_device(hive_id="sens-1", device_id="dev-1", hive_type="contactsensor") + result = await sensor.get_state(d) + assert result is None + + async def test_get_state_missing_props_key_returns_none(self): + """contactsensor product without 'props' causes KeyError → None.""" + session = _make_session({"sens-1": {"type": "contactsensor"}}) + sensor = Sensor(session=session) + d = _make_device(hive_id="sens-1", device_id="dev-1", hive_type="contactsensor") + result = await sensor.get_state(d) + assert result is None + + +class TestSensorGetStateUnknownType: + """Lines 37->42: data['type'] is neither contactsensor nor motionsensor.""" + + async def test_unknown_type_returns_none(self): + """Product with type 'hub' skips both if/elif → final stays None.""" + session = _make_session({"sens-1": {"type": "hub", "props": {}}}) + sensor = Sensor(session=session) + d = _make_device(hive_id="sens-1", device_id="dev-1", hive_type="contactsensor") + result = await sensor.get_state(d) + assert result is None + + +class TestSensorGetSensorCacheMiss: + """Lines 92->98: should_use_cached_data=True but cached is None.""" + + async def test_cached_none_falls_through(self): + session = _make_session( + products={"sens-1": {"type": "contactsensor", "props": {"status": "OPEN"}}}, + devices={"dev-1": {"props": {"online": True}, "type": "contactsensor"}}, + ) + session.should_use_cached_data.return_value = True + session.get_cached_device.return_value = None + sensor = Sensor(session=session) + d = _make_device(hive_id="sens-1", device_id="dev-1", hive_type="contactsensor") + result = await sensor.get_sensor(d) + assert result is not None + session.attr.online_offline.assert_called_once() + + +class TestSensorGetSensorNoDataFallthrough: + """Lines 119->122: device_id not in devices AND hive_id not in products.""" + + async def test_neither_match_continues_with_empty_data(self): + """data stays empty dict when neither lookup succeeds.""" + session = _make_session(products={}, devices={}) + sensor = Sensor(session=session) + d = _make_device( + hive_id="unknown-hive", + device_id="unknown-dev", + hive_type="contactsensor", + ) + result = await sensor.get_sensor(d) + # Should not raise; result will be the device (set_cached_device returns it) + assert result is not None + + +class TestSensorGetSensorUnknownHiveType: + """Lines 135->146: hive_type not in sensor_commands and not in HIVE_TYPES['Sensor'].""" + + async def test_hive_type_not_in_either_dict_skips_both_branches(self): + """activeplug is neither in sensor_commands nor HIVE_TYPES['Sensor'].""" + session = _make_session( + devices={"dev-1": {"props": {"online": True}, "type": "activeplug"}} + ) + sensor = Sensor(session=session) + d = _make_device(hive_id="dev-1", device_id="dev-1", hive_type="activeplug") + d.device_data = {"online": True} + result = await sensor.get_sensor(d) + # Neither branch sets device.status; device returned as-is via set_cached_device + assert result is not None diff --git a/tests/unit/test_session_auth_extended.py b/tests/unit/test_session_auth_extended.py index 893753d..5f11837 100644 --- a/tests/unit/test_session_auth_extended.py +++ b/tests/unit/test_session_auth_extended.py @@ -353,3 +353,130 @@ async def test_missing_refresh_token_does_not_raise_key_error(self): s.auth.refresh_token.return_value = None result = await s.hive_refresh_tokens() assert result is None + + +# =========================================================================== +# Migrated from test_remaining_branches.py +# =========================================================================== + + +class TestRetryWithBackoffNonZeroDelay: + """Line 66: asyncio.sleep called when delay > 0.""" + + async def test_non_zero_delay_is_awaited_but_succeeds(self): + """A non-zero delay entry causes asyncio.sleep to be called; factory still runs.""" + from unittest.mock import patch + + s = _make_stub() + calls = [] + + async def factory(): + calls.append(1) + return "ok" + + with patch( + "apyhiveapi.session.auth.asyncio.sleep", new_callable=AsyncMock + ) as mock_sleep: + result = await s._retry_with_backoff(factory, delays=(5,)) + assert result == "ok" + mock_sleep.assert_called_once_with(5) + assert len(calls) == 1 + + async def test_zero_delay_does_not_call_sleep(self): + """A zero delay skips asyncio.sleep.""" + from unittest.mock import patch + + s = _make_stub() + + async def factory(): + return "done" + + with patch( + "apyhiveapi.session.auth.asyncio.sleep", new_callable=AsyncMock + ) as mock_sleep: + result = await s._retry_with_backoff(factory, delays=(0,)) + assert result == "done" + mock_sleep.assert_not_called() + + +class TestUpdateTokensFlatDictWithExpiresIn: + """Lines 100->106: flat token dict with ExpiresIn sets token_expiry.""" + + async def test_flat_dict_with_expires_in_sets_token_expiry(self): + """Flat token dict containing ExpiresIn updates tokens.token_expiry.""" + s = _make_stub() + flat = { + "token": "t", + "refreshToken": "r", + "accessToken": "a", + "ExpiresIn": 1800, + } + await s.update_tokens(flat) + assert s.tokens.token_expiry == timedelta(seconds=1800) + + async def test_flat_dict_tokens_are_stored(self): + """All token values from flat dict are written to token_data.""" + s = _make_stub() + flat = {"token": "my-id", "refreshToken": "my-rt", "accessToken": "my-at"} + await s.update_tokens(flat) + assert s.tokens.token_data["token"] == "my-id" + assert s.tokens.token_data["refreshToken"] == "my-rt" + assert s.tokens.token_data["accessToken"] == "my-at" + + +class TestLoginApiError: + """Lines 160-162: HiveApiError in login() is logged and re-raised.""" + + async def test_login_api_error_reraises(self): + """HiveApiError raised by auth.login propagates unchanged to the caller.""" + s = _make_stub() + s.auth.login.side_effect = HiveApiError() + with pytest.raises(HiveApiError): + await s.login() + + +class TestHiveRefreshTokensNoAuthResult: + """Lines 341->373: refresh returns a result but without AuthenticationResult.""" + + async def test_result_without_auth_result_does_not_update_tokens(self): + """When refresh_token returns a dict with no AuthenticationResult, tokens stay unchanged.""" + s = _make_stub() + s.tokens.token_created = datetime.now() - timedelta(hours=2) + s.tokens.token_expiry = timedelta(hours=1) + # Return something truthy but without AuthenticationResult + s.auth.refresh_token.return_value = {"SomeOtherKey": "value"} + result = await s.hive_refresh_tokens() + # Tokens must not have been updated + assert s.tokens.token_data["token"] == "" + assert s.tokens.token_data["accessToken"] == "" + # result is what refresh_token returned + assert result == {"SomeOtherKey": "value"} + + async def test_none_refresh_result_does_not_update_tokens(self): + """When refresh_token returns None, tokens are left unchanged.""" + s = _make_stub() + s.tokens.token_created = datetime.now() - timedelta(hours=2) + s.tokens.token_expiry = timedelta(hours=1) + s.auth.refresh_token.return_value = None + await s.hive_refresh_tokens() + assert s.tokens.token_data["token"] == "" + + +class TestUpdateTokensUnknownKey: + """session/auth.py 100->106: tokens dict has neither AuthenticationResult nor token.""" + + async def test_unknown_key_does_not_raise_and_does_not_update_tokens(self): + """When neither expected key is present, data stays {}, ExpiresIn check skips.""" + s = _make_stub() + original_token = s.tokens.token_data["token"] + # Pass a dict that is neither the AuthResult form nor the flat-token form + await s.update_tokens({"some_other_key": "some_value"}) + # Tokens must be unchanged + assert s.tokens.token_data["token"] == original_token + + async def test_unknown_key_does_not_set_token_expiry(self): + """ExpiresIn check at line 106 skips when data is {} (no match in either branch).""" + s = _make_stub() + original_expiry = s.tokens.token_expiry + await s.update_tokens({"random_key": "random_value"}) + assert s.tokens.token_expiry == original_expiry diff --git a/tests/unit/test_session_discovery_extended.py b/tests/unit/test_session_discovery_extended.py index a9d9aad..48d1c02 100644 --- a/tests/unit/test_session_discovery_extended.py +++ b/tests/unit/test_session_discovery_extended.py @@ -2,14 +2,14 @@ # pylint: disable=attribute-defined-outside-init,too-few-public-methods,protected-access from datetime import datetime -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from apyhiveapi.helper.hive_exceptions import ( HiveReauthRequired, HiveUnknownConfiguration, ) -from apyhiveapi.helper.hivedataclasses import SessionConfig, SessionTokens +from apyhiveapi.helper.hivedataclasses import EntityConfig, SessionConfig, SessionTokens from apyhiveapi.helper.map import Map from apyhiveapi.session.discovery import DiscoveryMixin @@ -379,3 +379,212 @@ async def test_mode_product_without_id_does_not_crash(self): await s.create_devices() except KeyError as err: pytest.fail(f"KeyError raised for missing 'id' in product: {err}") + + +# =========================================================================== +# Migrated from test_remaining_branches.py +# =========================================================================== + + +class TestCreateDevicesEntityConfigKwargs: + """Lines 224->226, 226->228, 228->230: entity_config kwarg population in DEVICES loop.""" + + async def test_entity_config_with_all_fields_populates_kwargs(self): + """EntityConfig with ha_name, hive_type, and category all set → all kwargs passed.""" + s = _make_create_stub() + s.data["devices"] = { + "dev-1": { + "id": "dev-1", + "type": "hub", + "state": {"name": "My Hub"}, + "props": {}, + } + } + entity_cfg = EntityConfig( + entity_type="binary_sensor", + ha_name="Hub Status", + hive_type="Connectivity", + category="diagnostic", + ) + with patch("apyhiveapi.session.discovery.DEVICES", {"hub": [entity_cfg]}): + result = await s.create_devices() + assert len(result["binary_sensor"]) == 1 + created = result["binary_sensor"][0] + assert created.hive_type == "Connectivity" + assert created.category == "diagnostic" + + async def test_entity_config_empty_fields_does_not_add_to_kwargs(self): + """EntityConfig with empty ha_name and hive_type does not inject those keys.""" + s = _make_create_stub() + s.data["devices"] = { + "dev-1": { + "id": "dev-1", + "type": "hub", + "state": {"name": "My Hub"}, + "props": {}, + } + } + entity_cfg = EntityConfig( + entity_type="binary_sensor", + ha_name="", # falsy — should not be added to kwargs + hive_type="", # falsy — should not be added to kwargs + category=None, # None — should not be added to kwargs + ) + with patch("apyhiveapi.session.discovery.DEVICES", {"hub": [entity_cfg]}): + result = await s.create_devices() + # Should still process without error + assert isinstance(result, dict) + + +class TestCreateDevicesDeviceAddListError: + """Lines 232-233: KeyError/TypeError from add_list in DEVICES loop is caught.""" + + async def test_add_list_keyerror_is_caught_not_raised(self): + """KeyError from add_list during device processing is logged, not propagated.""" + s = _make_create_stub() + s.data["devices"] = { + "dev-1": { + "id": "dev-1", + "type": "hub", + "state": {"name": "My Hub"}, + "props": {}, + } + } + entity_cfg = EntityConfig( + entity_type="binary_sensor", + ha_name="Hub Status", + hive_type="Connectivity", + category="diagnostic", + ) + with patch("apyhiveapi.session.discovery.DEVICES", {"hub": [entity_cfg]}): + with patch.object(s, "add_list", side_effect=KeyError("bad key")): + # Should complete without raising + result = await s.create_devices() + assert isinstance(result, dict) + + async def test_add_list_typeerror_is_caught_not_raised(self): + """TypeError from add_list during device processing is caught.""" + s = _make_create_stub() + s.data["devices"] = { + "dev-1": { + "id": "dev-1", + "type": "hub", + "state": {"name": "My Hub"}, + "props": {}, + } + } + entity_cfg = EntityConfig( + entity_type="binary_sensor", + ha_name="", + hive_type="", + category=None, + ) + with patch("apyhiveapi.session.discovery.DEVICES", {"hub": [entity_cfg]}): + with patch.object(s, "add_list", side_effect=TypeError("bad type")): + result = await s.create_devices() + assert isinstance(result, dict) + + +class TestCreateDevicesActionAddListError: + """Lines 258-259: KeyError/TypeError from add_list in actions loop is caught.""" + + async def test_action_add_list_keyerror_is_caught(self): + """KeyError from add_list when processing an action is logged, not propagated.""" + s = _make_create_stub() + s.data["actions"] = {"act-1": {"id": "act-1", "name": "Good Night"}} + with patch.object(s, "add_list", side_effect=KeyError("missing")): + result = await s.create_devices() + assert isinstance(result, dict) + + async def test_action_add_list_typeerror_is_caught(self): + """TypeError from add_list when processing an action is caught.""" + s = _make_create_stub() + s.data["actions"] = {"act-1": {"id": "act-1", "name": "Wake Up"}} + with patch.object(s, "add_list", side_effect=TypeError("type error")): + result = await s.create_devices() + assert isinstance(result, dict) + + +class TestCreateDevicesProductTemperatureUnit: + """Line 305: entity_config.temperature_unit is used when set and entity_type != 'climate'.""" + + async def test_entity_config_temperature_unit_passed_to_add_list(self): + """EntityConfig with temperature_unit set propagates that value as a kwarg.""" + s = _make_create_stub() + s.data["products"] = { + "prod-1": { + "id": "prod-1", + "type": "heating", + "state": {"name": "Heating"}, + "props": {}, + } + } + # A non-climate entity with temperature_unit set triggers line 305 + entity_cfg = EntityConfig( + entity_type="sensor", + ha_name="Temp Sensor", + hive_type="Current_Temperature", + category="diagnostic", + temperature_unit="F", + ) + captured_kwargs = {} + + original_add_list = s.add_list + + def capturing_add_list(entity_type, data, **kwargs): + captured_kwargs.update(kwargs) + return original_add_list(entity_type, data, **kwargs) + + with patch("apyhiveapi.session.discovery.PRODUCTS", {"heating": [entity_cfg]}): + with patch.object(s, "add_list", side_effect=capturing_add_list): + await s.create_devices() + + assert captured_kwargs.get("temperature_unit") == "F" + + +class TestCreateDevicesProductAddListAttributeError: + """Lines 308-309: NameError/AttributeError from add_list in products loop is caught.""" + + async def test_product_add_list_attribute_error_is_caught(self): + """AttributeError from add_list when processing a product is caught.""" + s = _make_create_stub() + s.data["products"] = { + "prod-1": { + "id": "prod-1", + "type": "heating", + "state": {"name": "Heating"}, + "props": {}, + } + } + entity_cfg = EntityConfig( + entity_type="climate", + ha_name="", + hive_type="", + category=None, + ) + with patch("apyhiveapi.session.discovery.PRODUCTS", {"heating": [entity_cfg]}): + with patch.object(s, "add_list", side_effect=AttributeError("attr error")): + result = await s.create_devices() + assert isinstance(result, dict) + + async def test_product_add_list_name_error_is_caught(self): + """NameError from add_list when processing a product is caught.""" + s = _make_create_stub() + s.data["products"] = { + "prod-1": { + "id": "prod-1", + "type": "heating", + "state": {"name": "Heating"}, + "props": {}, + } + } + entity_cfg = EntityConfig( + entity_type="climate", + ha_name="", + hive_type="", + category=None, + ) + with patch("apyhiveapi.session.discovery.PRODUCTS", {"heating": [entity_cfg]}): + with patch.object(s, "add_list", side_effect=NameError("name error")): + result = await s.create_devices() + assert isinstance(result, dict) From a773b796d6522b19b57d0627912cf9b52559fce3 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Wed, 27 May 2026 18:41:55 +0100 Subject: [PATCH 26/29] test: remove 3 duplicate tests from module files test_schedule_mode_returns_nnl, test_motionsensor_returns_motion_status, and test_empty_devices_after_get_devices_raises_unknown_configuration were identical (same mocked setup, same assertions) to their counterparts in the corresponding unit/_extended files. Removed from module files; unit versions retained. Coverage remains at 100%. Co-Authored-By: Claude Sonnet 4.6 --- tests/module/test_hotwater.py | 8 -------- tests/module/test_sensor.py | 13 ------------- tests/module/test_session_discovery.py | 7 ------- 3 files changed, 28 deletions(-) diff --git a/tests/module/test_hotwater.py b/tests/module/test_hotwater.py index b8f7433..f3ac68f 100644 --- a/tests/module/test_hotwater.py +++ b/tests/module/test_hotwater.py @@ -188,14 +188,6 @@ async def test_boosting_calls_execute_with_prev_mode(self): class TestGetScheduleNowNextLater: """Tests for WaterHeater.get_schedule_now_next_later.""" - async def test_schedule_mode_returns_nnl(self): - """SCHEDULE mode with a schedule returns the now/next/later dict.""" - hw = _make_hotwater( - {"hw-1": {"state": {"mode": _SCHEDULE_MODE, "schedule": {}}}} - ) - result = await hw.get_schedule_now_next_later(_make_device()) - assert result is not None - async def test_non_schedule_returns_none(self): """Non-SCHEDULE mode returns None.""" hw = _make_hotwater({"hw-1": {"state": {"mode": _ON_MODE}}}) diff --git a/tests/module/test_sensor.py b/tests/module/test_sensor.py index f8cb038..ac0bfa5 100644 --- a/tests/module/test_sensor.py +++ b/tests/module/test_sensor.py @@ -65,19 +65,6 @@ async def test_contactsensor_closed_returns_false(self): ) assert await sensor.get_state(_make_device()) is False - async def test_motionsensor_returns_motion_status(self): - """get_state returns the motion status boolean for a motionsensor.""" - sensor = _make_sensor( - products={ - "sens-1": { - "type": "motionsensor", - "props": {"motion": {"status": True}}, - } - } - ) - result = await sensor.get_state(_make_device(hive_type="motionsensor")) - assert result is True - async def test_missing_key_returns_none(self): """get_state returns None when the hive_id is not in products.""" sensor = _make_sensor() diff --git a/tests/module/test_session_discovery.py b/tests/module/test_session_discovery.py index afdb4ec..cfb67fa 100644 --- a/tests/module/test_session_discovery.py +++ b/tests/module/test_session_discovery.py @@ -102,13 +102,6 @@ async def test_file_mode_username_enables_file_and_succeeds(self): assert s.config.file is True s.get_devices.assert_called_once() - async def test_empty_devices_after_get_devices_raises_unknown_configuration(self): - """start_session raises HiveUnknownConfiguration when data.devices is empty post-poll.""" - s = _make_stub(has_data=False) - s.config.file = True - with pytest.raises(HiveUnknownConfiguration): - await s.start_session({}) - async def test_no_tokens_in_non_file_config_raises_unknown_configuration(self): """Non-file mode config without tokens raises HiveUnknownConfiguration.""" s = _make_stub() From f2ebaebd65a42029ed1727b9ea84361e7e93628c Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Wed, 27 May 2026 19:17:24 +0100 Subject: [PATCH 27/29] refactor: eliminate _extended test files by renaming and merging Renamed 9 standalone extended files (git mv) and merged 4 extended files into their base counterparts, deduplicating helpers and consolidating all test classes under clean names with no _extended suffix. Co-Authored-By: Claude Sonnet 4.6 --- .secrets.baseline | 6 +- ...test_action_extended.py => test_action.py} | 0 .../{test_boost_extended.py => test_boost.py} | 0 .../{test_color_extended.py => test_color.py} | 0 tests/unit/test_compat_aliases.py | 83 ++ tests/unit/test_compat_aliases_extended.py | 94 -- ...st_heating_extended.py => test_heating.py} | 0 tests/unit/test_helpers.py | 107 ++ tests/unit/test_hive_async_api.py | 250 +++- tests/unit/test_hive_async_api_extended.py | 323 ----- tests/unit/test_hive_auth_async.py | 845 +++++++++++++ tests/unit/test_hive_auth_async_extended.py | 1089 ----------------- tests/unit/test_hive_helper_extended.py | 186 --- ..._hotwater_extended.py => test_hotwater.py} | 0 .../{test_light_extended.py => test_light.py} | 0 ...test_sensor_extended.py => test_sensor.py} | 0 ..._auth_extended.py => test_session_auth.py} | 0 ..._extended.py => test_session_discovery.py} | 2 +- 18 files changed, 1288 insertions(+), 1697 deletions(-) rename tests/unit/{test_action_extended.py => test_action.py} (100%) rename tests/unit/{test_boost_extended.py => test_boost.py} (100%) rename tests/unit/{test_color_extended.py => test_color.py} (100%) delete mode 100644 tests/unit/test_compat_aliases_extended.py rename tests/unit/{test_heating_extended.py => test_heating.py} (100%) delete mode 100644 tests/unit/test_hive_async_api_extended.py delete mode 100644 tests/unit/test_hive_auth_async_extended.py delete mode 100644 tests/unit/test_hive_helper_extended.py rename tests/unit/{test_hotwater_extended.py => test_hotwater.py} (100%) rename tests/unit/{test_light_extended.py => test_light.py} (100%) rename tests/unit/{test_sensor_extended.py => test_sensor.py} (100%) rename tests/unit/{test_session_auth_extended.py => test_session_auth.py} (100%) rename tests/unit/{test_session_discovery_extended.py => test_session_discovery.py} (99%) diff --git a/.secrets.baseline b/.secrets.baseline index 8c40695..b80d7c3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -486,14 +486,14 @@ "filename": "tests/unit/test_hive_auth_async.py", "hashed_secret": "5c5a15a8b0b3e154d77746945e563ba40100681b", "is_verified": false, - "line_number": 165 + "line_number": 173 }, { "type": "Secret Keyword", "filename": "tests/unit/test_hive_auth_async.py", "hashed_secret": "d8bce9746547bb7743e5933fbf0fc4f2d2cbcad3", "is_verified": false, - "line_number": 221 + "line_number": 229 } ], "tests/unit/test_hive_auth_async_extended.py": [ @@ -580,5 +580,5 @@ } ] }, - "generated_at": "2026-05-25T16:53:38Z" + "generated_at": "2026-05-27T18:17:19Z" } diff --git a/tests/unit/test_action_extended.py b/tests/unit/test_action.py similarity index 100% rename from tests/unit/test_action_extended.py rename to tests/unit/test_action.py diff --git a/tests/unit/test_boost_extended.py b/tests/unit/test_boost.py similarity index 100% rename from tests/unit/test_boost_extended.py rename to tests/unit/test_boost.py diff --git a/tests/unit/test_color_extended.py b/tests/unit/test_color.py similarity index 100% rename from tests/unit/test_color_extended.py rename to tests/unit/test_color.py diff --git a/tests/unit/test_compat_aliases.py b/tests/unit/test_compat_aliases.py index ec6c44e..8cc255f 100644 --- a/tests/unit/test_compat_aliases.py +++ b/tests/unit/test_compat_aliases.py @@ -5,8 +5,10 @@ from unittest.mock import AsyncMock from apyhiveapi.helper.compat_aliases import ( + ActionCompatMixin, HeatingCompatMixin, LightCompatMixin, + SensorCompatMixin, SessionCompatMixin, SwitchCompatMixin, WaterHeaterCompatMixin, @@ -374,3 +376,84 @@ async def test_update_interval_returns_true(self): session = _make_concrete_session() result = await session.updateInterval(60) assert result is True + + +# --------------------------------------------------------------------------- +# Migrated from test_compat_aliases_extended.py +# --------------------------------------------------------------------------- + + +def _make_action_device(hive_type="action", ha_type="switch"): + return Device( + hive_id="h1", + hive_name="Test", + hive_type=hive_type, + ha_type=ha_type, + device_id="d1", + device_name="Test", + device_data={}, + ) + + +class TestSensorCompatMixin: + """CamelCase alias smoke tests for SensorCompatMixin.""" + + async def test_get_sensor_delegates(self): + """getSensor delegates to get_sensor and returns its result.""" + + class Stub(SensorCompatMixin): + """Stub with mocked get_sensor.""" + + get_sensor = AsyncMock(return_value="sensor_result") + + s = Stub() + d = _make_action_device(hive_type="motionsensor", ha_type="binary_sensor") + result = await s.getSensor(d) + s.get_sensor.assert_called_once_with(d) + assert result == "sensor_result" + + +class TestActionCompatMixin: + """CamelCase alias smoke tests for ActionCompatMixin.""" + + async def test_get_action_delegates(self): + """getAction delegates to get_action and returns its result.""" + + class Stub(ActionCompatMixin): + """Stub with mocked get_action.""" + + get_action = AsyncMock(return_value="action_result") + + s = Stub() + d = _make_action_device() + result = await s.getAction(d) + s.get_action.assert_called_once_with(d) + assert result == "action_result" + + async def test_set_status_on_delegates(self): + """setStatusOn delegates to set_status_on and returns its result.""" + + class Stub(ActionCompatMixin): + """Stub with mocked set_status_on.""" + + set_status_on = AsyncMock(return_value=True) + + s = Stub() + d = _make_action_device() + result = await s.setStatusOn(d) + s.set_status_on.assert_called_once_with(d) + assert result is True + + async def test_set_status_off_delegates(self): + """setStatusOff delegates to set_status_off and returns its result.""" + + class Stub(ActionCompatMixin): + """Stub with mocked set_status_off.""" + + set_status_off = AsyncMock(return_value=True) + + s = Stub() + d = _make_action_device() + result = await s.setStatusOff(d) + s.set_status_off.assert_called_once_with(d) + assert result is True diff --git a/tests/unit/test_compat_aliases_extended.py b/tests/unit/test_compat_aliases_extended.py deleted file mode 100644 index e103f48..0000000 --- a/tests/unit/test_compat_aliases_extended.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Tests for SensorCompatMixin and ActionCompatMixin aliases (coverage gap fill).""" - -# pylint: disable=too-few-public-methods - -from unittest.mock import AsyncMock - -from apyhiveapi.helper.compat_aliases import ActionCompatMixin, SensorCompatMixin -from apyhiveapi.helper.hivedataclasses import Device - - -def _make_device(hive_type="action", ha_type="switch"): - return Device( - hive_id="h1", - hive_name="Test", - hive_type=hive_type, - ha_type=ha_type, - device_id="d1", - device_name="Test", - device_data={}, - ) - - -# --------------------------------------------------------------------------- -# SensorCompatMixin -# --------------------------------------------------------------------------- - - -class TestSensorCompatMixin: - """CamelCase alias smoke tests for SensorCompatMixin.""" - - async def test_get_sensor_delegates(self): - """getSensor delegates to get_sensor and returns its result.""" - - class Stub(SensorCompatMixin): - """Stub with mocked get_sensor.""" - - get_sensor = AsyncMock(return_value="sensor_result") - - s = Stub() - d = _make_device(hive_type="motionsensor", ha_type="binary_sensor") - result = await s.getSensor(d) - s.get_sensor.assert_called_once_with(d) - assert result == "sensor_result" - - -# --------------------------------------------------------------------------- -# ActionCompatMixin -# --------------------------------------------------------------------------- - - -class TestActionCompatMixin: - """CamelCase alias smoke tests for ActionCompatMixin.""" - - async def test_get_action_delegates(self): - """getAction delegates to get_action and returns its result.""" - - class Stub(ActionCompatMixin): - """Stub with mocked get_action.""" - - get_action = AsyncMock(return_value="action_result") - - s = Stub() - d = _make_device() - result = await s.getAction(d) - s.get_action.assert_called_once_with(d) - assert result == "action_result" - - async def test_set_status_on_delegates(self): - """setStatusOn delegates to set_status_on and returns its result.""" - - class Stub(ActionCompatMixin): - """Stub with mocked set_status_on.""" - - set_status_on = AsyncMock(return_value=True) - - s = Stub() - d = _make_device() - result = await s.setStatusOn(d) - s.set_status_on.assert_called_once_with(d) - assert result is True - - async def test_set_status_off_delegates(self): - """setStatusOff delegates to set_status_off and returns its result.""" - - class Stub(ActionCompatMixin): - """Stub with mocked set_status_off.""" - - set_status_off = AsyncMock(return_value=True) - - s = Stub() - d = _make_device() - result = await s.setStatusOff(d) - s.set_status_off.assert_called_once_with(d) - assert result is True diff --git a/tests/unit/test_heating_extended.py b/tests/unit/test_heating.py similarity index 100% rename from tests/unit/test_heating_extended.py rename to tests/unit/test_heating.py diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index d7d4638..8ee6b13 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -202,6 +202,23 @@ def test_dict_under_sensitive_key_is_recursively_masked(self): assert "inner_key" in result["token"] assert result["token"]["inner_key"] != "secret_value" + def test_list_value_under_sensitive_key_masks_each_element(self): + """A list under a sensitive key has each element masked individually.""" + helper, _ = _make_helper() + payload = {"token": ["short", "averylongtoken123"]} + result = helper.sanitize_payload(payload) + assert result["token"] == ["***", "aver...n123"] + + def test_none_under_sensitive_key_passes_through(self): + """None under a sensitive key is returned unchanged.""" + helper, _ = _make_helper() + assert helper.sanitize_payload({"token": None})["token"] is None + + def test_bool_under_sensitive_key_passes_through(self): + """A bool under a sensitive key is returned unchanged.""" + helper, _ = _make_helper() + assert helper.sanitize_payload({"token": True})["token"] is True + # --------------------------------------------------------------------------- # HiveHelper.device_recovered @@ -566,3 +583,93 @@ def test_list_containing_dicts_is_walked_recursively(self): assert result["items"][0]["token"] != "abc" assert result["items"][1]["name"] == "device2" assert result["items"][1]["token"] != "xyz" + + +# --------------------------------------------------------------------------- +# Migrated from test_hive_helper_extended.py +# --------------------------------------------------------------------------- + + +class TestGetDeviceFromIdBranch: + """Covers the branch where no cache entry matches the requested ID.""" + + def test_returns_false_when_no_match_in_cache(self): + """When entity_cache has entries but none match n_id, returns False.""" + other_device = Device( + hive_id="other-hive-id", + hive_name="Other", + hive_type="heating", + ha_type="climate", + device_id="other-device-id", + device_name="Other", + device_data={}, + ) + helper, _ = _make_helper(entity_cache={"other-key": other_device}) + result = helper.get_device_from_id("nonexistent-id") + assert result is False + + def test_returns_false_when_cache_is_empty(self): + """When entity_cache is empty, returns False without entering the loop.""" + helper, _ = _make_helper(entity_cache={}) + assert helper.get_device_from_id("any-id") is False + + +class TestEpochTimePattern: + """epoch_time to_epoch must honour the pattern argument.""" + + def test_to_epoch_uses_caller_pattern(self): + """Passing a custom pattern must parse the date string with that pattern.""" + result = epoch_time("2024-06-15", "%Y-%m-%d", "to_epoch") + assert isinstance(result, int), "Expected int epoch timestamp" + assert result > 0 + + def test_to_epoch_standard_hive_format_still_works(self): + """The standard Hive date+time format must still parse correctly.""" + result = epoch_time("15.06.2024 12:00:00", "%d.%m.%Y %H:%M:%S", "to_epoch") + assert isinstance(result, int) + assert result > 0 + + +def _sample_schedule(): + """Minimal 7-day schedule with 3 slots on every day.""" + days = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ] + schedule = {} + for d in days: + schedule[d] = [ + {"start": 0, "value": {"status": "ON"}}, + {"start": 480, "value": {"status": "OFF"}}, + {"start": 1200, "value": {"status": "ON"}}, + ] + return schedule + + +class TestGetScheduleNnlMutation: + """get_schedule_nnl must not mutate the input schedule dicts.""" + + def test_second_call_returns_same_result_as_first_call(self): + """Calling get_schedule_nnl twice on the same schedule dict gives consistent results.""" + h, _ = _make_helper() + schedule = _sample_schedule() + result1 = h.get_schedule_nnl(schedule) + result2 = h.get_schedule_nnl(schedule) + assert result1.get("now", {}).get("value") == result2.get("now", {}).get( + "value" + ), "Second call returned different 'now' value — schedule was mutated in-place" + + def test_input_schedule_slots_not_modified(self): + """Slot dicts in the input schedule must not gain 'Start_DateTime' after the call.""" + h, _ = _make_helper() + schedule = _sample_schedule() + monday_slot_before = dict(schedule["monday"][0]) + h.get_schedule_nnl(schedule) + assert schedule["monday"][0] == monday_slot_before, ( + "get_schedule_nnl mutated the original slot dict" + ) diff --git a/tests/unit/test_hive_async_api.py b/tests/unit/test_hive_async_api.py index c630182..c063f31 100644 --- a/tests/unit/test_hive_async_api.py +++ b/tests/unit/test_hive_async_api.py @@ -1,7 +1,7 @@ """Unit tests for HiveApiAsync.""" import asyncio -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiohttp import web_exceptions @@ -336,3 +336,251 @@ def test_base_url_is_set(self): def test_default_timeout(self): api = _make_api() assert api.timeout == 5 + + +# --------------------------------------------------------------------------- +# Migrated from test_hive_async_api_extended.py +# --------------------------------------------------------------------------- + + +class TestRequestNonAuthErrorBranch: + """Cover lines 100-108: url/status not None branch leading to HiveApiError.""" + + async def test_404_logs_and_raises_hive_api_error(self): + """A 404 falls through to the url/status branch and raises HiveApiError.""" + api = _make_api(status=404) + with pytest.raises(HiveApiError): + await api.request("get", "https://beekeeper.hivehome.com/1.0/nodes/all") + + async def test_503_logs_and_raises_hive_api_error(self): + """A 503 falls through to the url/status branch and raises HiveApiError.""" + api = _make_api(status=503) + with pytest.raises(HiveApiError): + await api.request("get", "https://beekeeper.hivehome.com/1.0/nodes/all") + + async def test_422_logs_and_raises_hive_api_error(self): + """A 422 also falls through (not 401/403) and raises HiveApiError.""" + api = _make_api(status=422) + with pytest.raises(HiveApiError): + await api.request("get", "https://beekeeper.hivehome.com/1.0/devices") + + +class TestGetLoginInfo: + """Cover lines 112-129: get_login_info() parses HTML and returns login dict.""" + + def test_returns_upid_cliid_region(self): + """Successful fetch returns correct keys from parsed HTML.""" + html_content = ( + b"" + ) + mock_response = MagicMock() + mock_response.content = html_content + api = _make_api() + with patch( + "apyhiveapi.api.hive_async_api.requests.get", return_value=mock_response + ): + result = api.get_login_info() + assert result["UPID"] == "eu-west-1_abc123" + assert result["CLIID"] == "client-xyz" + assert result["REGION"] == "eu-west-1_abc123" + + def test_makes_request_to_sso_url(self): + """Verifies requests.get is called with the SSO URL.""" + html_content = ( + b"" + ) + mock_response = MagicMock() + mock_response.content = html_content + api = _make_api() + with patch( + "apyhiveapi.api.hive_async_api.requests.get", return_value=mock_response + ) as mock_get: + api.get_login_info() + mock_get.assert_called_once_with( + url="https://sso.hivehome.com/", timeout=api.timeout + ) + + def test_uses_first_script_tag(self): + """PyQuery selects the first script — extra scripts are ignored.""" + html_content = ( + b"" + b'' + ) + mock_response = MagicMock() + mock_response.content = html_content + api = _make_api() + with patch( + "apyhiveapi.api.hive_async_api.requests.get", return_value=mock_response + ): + result = api.get_login_info() + assert result["UPID"] == "eu-west-1_first" + + +class TestMotionSensorBranches: + """Cover lines 215-235: motion_sensor() success and error paths.""" + + async def test_success_returns_status_and_parsed(self): + """Successful call returns status and parsed JSON.""" + payload = [{"event": "motion", "timestamp": 1234567890}] + api = _make_api(status=200, json_data=payload) + api.urls["base"] = "" + sensor = {"type": "motionsensor", "id": "sensor-001"} + result = await api.motion_sensor(sensor, fromepoch=1000000, toepoch=2000000) + assert result["original"] == 200 + assert result["parsed"] == payload + + async def test_url_is_built_correctly(self): + """Verifies the URL is assembled with correct sensor type and id.""" + api = _make_api(status=200, json_data=[]) + api.urls["base"] = "https://beekeeper-uk.hivehome.com/1.0" + sensor = {"type": "contactsensor", "id": "abc-123"} + captured_url = [] + original_request = api.request + + async def capture_request(method, url, **kwargs): + captured_url.append(url) + return await original_request(method, url, **kwargs) + + with patch.object(api, "request", side_effect=capture_request): + await api.motion_sensor(sensor, fromepoch=100, toepoch=200) + assert len(captured_url) == 1 + assert "contactsensor" in captured_url[0] + assert "abc-123" in captured_url[0] + assert "from=100" in captured_url[0] + assert "to=200" in captured_url[0] + + async def test_os_error_raises_http_error(self): + """OSError inside the try block causes error() → HTTPError.""" + api = _make_api(status=200) + api.urls["base"] = "" + sensor = {"type": "motionsensor", "id": "sensor-001"} + api.websession.request.side_effect = OSError("fail") + with pytest.raises(web_exceptions.HTTPError): + await api.motion_sensor(sensor, fromepoch=1000, toepoch=2000) + + async def test_runtime_error_raises_http_error(self): + """RuntimeError inside the try block causes error() → HTTPError.""" + api = _make_api(status=200) + api.urls["base"] = "" + sensor = {"type": "motionsensor", "id": "sensor-002"} + api.websession.request.side_effect = RuntimeError("unexpected") + with pytest.raises(web_exceptions.HTTPError): + await api.motion_sensor(sensor, fromepoch=1000, toepoch=2000) + + async def test_zero_division_raises_http_error(self): + """ZeroDivisionError inside the try block causes error() → HTTPError.""" + api = _make_api(status=200) + api.urls["base"] = "" + sensor = {"type": "motionsensor", "id": "sensor-003"} + api.websession.request.side_effect = ZeroDivisionError() + with pytest.raises(web_exceptions.HTTPError): + await api.motion_sensor(sensor, fromepoch=1000, toepoch=2000) + + +class TestGetWeather: + """Cover lines 239-249: get_weather() success, space encoding, and error paths.""" + + async def test_success_returns_status_and_parsed(self): + """Successful call returns status and parsed weather JSON.""" + payload = {"temperature": {"value": 15, "unit": "C"}} + api = _make_api(status=200, json_data=payload) + result = await api.get_weather("?lat=51.5&lon=-0.1") + assert result["original"] == 200 + assert result["parsed"] == payload + + async def test_space_in_weather_url_is_encoded(self): + """Spaces in the weather_url are replaced with %20.""" + api = _make_api(status=200, json_data={}) + captured_url = [] + original_request = api.request + + async def capture_request(method, url, **kwargs): + captured_url.append(url) + return await original_request(method, url, **kwargs) + + with patch.object(api, "request", side_effect=capture_request): + await api.get_weather("?postcode=SW1A 2AA") + assert len(captured_url) == 1 + assert " " not in captured_url[0] + assert "%20" in captured_url[0] + + async def test_url_is_prefixed_with_weather_base(self): + """The weather base URL is prepended to the given weather_url.""" + api = _make_api(status=200, json_data={}) + captured_url = [] + original_request = api.request + + async def capture_request(method, url, **kwargs): + captured_url.append(url) + return await original_request(method, url, **kwargs) + + with patch.object(api, "request", side_effect=capture_request): + await api.get_weather("?lat=51.5") + assert captured_url[0].startswith("https://weather.prod.bgchprod.info/weather") + + async def test_os_error_raises_http_error(self): + """OSError inside the try block causes error() → HTTPError.""" + api = _make_api(status=200) + api.websession.request.side_effect = OSError("network fail") + with pytest.raises(web_exceptions.HTTPError): + await api.get_weather("?lat=51.5") + + async def test_runtime_error_raises_http_error(self): + """RuntimeError inside the try block causes error() → HTTPError.""" + api = _make_api(status=200) + api.websession.request.side_effect = RuntimeError("unexpected") + with pytest.raises(web_exceptions.HTTPError): + await api.get_weather("?lat=51.5") + + async def test_zero_division_raises_http_error(self): + """ZeroDivisionError inside the try block causes error() → HTTPError.""" + api = _make_api(status=200) + api.websession.request.side_effect = ZeroDivisionError() + with pytest.raises(web_exceptions.HTTPError): + await api.get_weather("?lat=51.5") + + async def test_connection_error_raises_http_error(self): + """ConnectionError inside the try block causes error() → HTTPError.""" + api = _make_api(status=200) + api.websession.request.side_effect = ConnectionError("disconnected") + with pytest.raises(web_exceptions.HTTPError): + await api.get_weather("?lat=51.5") + + +class TestSetStateJsonEncoding: + """set_state must produce valid JSON even when kwarg values contain special characters.""" + + async def test_set_state_escapes_quotes_in_value(self): + """A value containing double-quotes must produce valid, parseable JSON.""" + import json # noqa: PLC0415 + + session = MagicMock() + session.tokens.token_data = {"token": "tok"} + session.config.file = False + api = HiveApiAsync(hive_session=session) + api.urls = {"nodes": "https://beekeeper.hivehome.com/1.0/nodes/{}/{}"} + + captured = {} + + async def fake_request(_method, _url, **kwargs): + captured["data"] = kwargs.get("data") + resp = MagicMock() + resp.status = 200 + resp.json = AsyncMock(return_value={}) + return resp + + with patch.object(api, "request", side_effect=fake_request): + with patch.object(api, "is_file_being_used", new=AsyncMock()): + await api.set_state("heating", "node-1", mode='MANUAL"injected') + + parsed = json.loads(captured["data"]) + assert parsed["mode"] == 'MANUAL"injected' diff --git a/tests/unit/test_hive_async_api_extended.py b/tests/unit/test_hive_async_api_extended.py deleted file mode 100644 index eabf190..0000000 --- a/tests/unit/test_hive_async_api_extended.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Extended unit tests for HiveApiAsync — covers previously uncovered lines.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from aiohttp import web_exceptions -from apyhiveapi.api.hive_async_api import HiveApiAsync -from apyhiveapi.helper.hive_exceptions import HiveApiError - -# --------------------------------------------------------------------------- -# Shared helpers (same pattern as test_hive_async_api.py) -# --------------------------------------------------------------------------- - - -def _make_mock_response(status=200, json_data=None): - resp = MagicMock() - resp.status = status - resp.text = AsyncMock(return_value="") - resp.json = AsyncMock(return_value=json_data or {"data": "test"}) - resp.__aenter__ = AsyncMock(return_value=resp) - resp.__aexit__ = AsyncMock(return_value=False) - return resp - - -def _make_api(status=200, json_data=None, token="test-token", file_mode=False): - resp = _make_mock_response(status=status, json_data=json_data) - websession = MagicMock() - websession.request.return_value = resp - websession.closed = False - websession.close = AsyncMock() - session = MagicMock() - session.tokens = MagicMock() - session.tokens.token_data = {"token": token} - session.config = MagicMock() - session.config.file = file_mode - return HiveApiAsync(hive_session=session, websession=websession) - - -# --------------------------------------------------------------------------- -# Tests: request() branch — url is not None and status is not None (non-auth error) -# --------------------------------------------------------------------------- - - -class TestRequestNonAuthErrorBranch: - """Cover lines 100-108: url/status not None branch leading to HiveApiError.""" - - async def test_404_logs_and_raises_hive_api_error(self): - """A 404 falls through to the url/status branch and raises HiveApiError.""" - api = _make_api(status=404) - with pytest.raises(HiveApiError): - await api.request("get", "https://beekeeper.hivehome.com/1.0/nodes/all") - - async def test_503_logs_and_raises_hive_api_error(self): - """A 503 falls through to the url/status branch and raises HiveApiError.""" - api = _make_api(status=503) - with pytest.raises(HiveApiError): - await api.request("get", "https://beekeeper.hivehome.com/1.0/nodes/all") - - async def test_422_logs_and_raises_hive_api_error(self): - """A 422 also falls through (not 401/403) and raises HiveApiError.""" - api = _make_api(status=422) - with pytest.raises(HiveApiError): - await api.request("get", "https://beekeeper.hivehome.com/1.0/devices") - - -# --------------------------------------------------------------------------- -# Tests: get_login_info() — sync method (lines 110-129) -# --------------------------------------------------------------------------- - - -class TestGetLoginInfo: - """Cover lines 112-129: get_login_info() parses HTML and returns login dict.""" - - def test_returns_upid_cliid_region(self): - """Successful fetch returns correct keys from parsed HTML.""" - html_content = ( - b"" - ) - mock_response = MagicMock() - mock_response.content = html_content - - api = _make_api() - with patch( - "apyhiveapi.api.hive_async_api.requests.get", return_value=mock_response - ): - result = api.get_login_info() - - assert result["UPID"] == "eu-west-1_abc123" - assert result["CLIID"] == "client-xyz" - # REGION is set to HiveSSOPoolId value - assert result["REGION"] == "eu-west-1_abc123" - - def test_makes_request_to_sso_url(self): - """Verifies requests.get is called with the SSO URL.""" - html_content = ( - b"" - ) - mock_response = MagicMock() - mock_response.content = html_content - - api = _make_api() - with patch( - "apyhiveapi.api.hive_async_api.requests.get", return_value=mock_response - ) as mock_get: - api.get_login_info() - - mock_get.assert_called_once_with( - url="https://sso.hivehome.com/", timeout=api.timeout - ) - - def test_uses_first_script_tag(self): - """PyQuery selects the first script — extra scripts are ignored.""" - html_content = ( - b"" - b'' - ) - mock_response = MagicMock() - mock_response.content = html_content - - api = _make_api() - with patch( - "apyhiveapi.api.hive_async_api.requests.get", return_value=mock_response - ): - result = api.get_login_info() - - assert result["UPID"] == "eu-west-1_first" - - -# --------------------------------------------------------------------------- -# Tests: motion_sensor() — lines 213-235 -# --------------------------------------------------------------------------- - - -class TestMotionSensor: - """Cover lines 215-235: motion_sensor() success and error paths.""" - - async def test_success_returns_status_and_parsed(self): - """Successful call returns status and parsed JSON.""" - payload = [{"event": "motion", "timestamp": 1234567890}] - api = _make_api(status=200, json_data=payload) - # motion_sensor uses urls["base"] which doesn't exist in HiveApiAsync; - # add it so the URL can be constructed - api.urls["base"] = "" - sensor = {"type": "motionsensor", "id": "sensor-001"} - - result = await api.motion_sensor(sensor, fromepoch=1000000, toepoch=2000000) - - assert result["original"] == 200 - assert result["parsed"] == payload - - async def test_url_is_built_correctly(self): - """Verifies the URL is assembled with correct sensor type and id.""" - api = _make_api(status=200, json_data=[]) - api.urls["base"] = "https://beekeeper-uk.hivehome.com/1.0" - sensor = {"type": "contactsensor", "id": "abc-123"} - - captured_url = [] - original_request = api.request - - async def capture_request(method, url, **kwargs): - captured_url.append(url) - return await original_request(method, url, **kwargs) - - with patch.object(api, "request", side_effect=capture_request): - await api.motion_sensor(sensor, fromepoch=100, toepoch=200) - - assert len(captured_url) == 1 - assert "contactsensor" in captured_url[0] - assert "abc-123" in captured_url[0] - assert "from=100" in captured_url[0] - assert "to=200" in captured_url[0] - - async def test_os_error_raises_http_error(self): - """OSError inside the try block causes error() → HTTPError.""" - api = _make_api(status=200) - api.urls["base"] = "" - sensor = {"type": "motionsensor", "id": "sensor-001"} - api.websession.request.side_effect = OSError("fail") - with pytest.raises(web_exceptions.HTTPError): - await api.motion_sensor(sensor, fromepoch=1000, toepoch=2000) - - async def test_runtime_error_raises_http_error(self): - """RuntimeError inside the try block causes error() → HTTPError.""" - api = _make_api(status=200) - api.urls["base"] = "" - sensor = {"type": "motionsensor", "id": "sensor-002"} - api.websession.request.side_effect = RuntimeError("unexpected") - with pytest.raises(web_exceptions.HTTPError): - await api.motion_sensor(sensor, fromepoch=1000, toepoch=2000) - - async def test_zero_division_raises_http_error(self): - """ZeroDivisionError inside the try block causes error() → HTTPError.""" - api = _make_api(status=200) - api.urls["base"] = "" - sensor = {"type": "motionsensor", "id": "sensor-003"} - api.websession.request.side_effect = ZeroDivisionError() - with pytest.raises(web_exceptions.HTTPError): - await api.motion_sensor(sensor, fromepoch=1000, toepoch=2000) - - -# --------------------------------------------------------------------------- -# Tests: get_weather() — lines 237-249 -# --------------------------------------------------------------------------- - - -class TestGetWeather: - """Cover lines 239-249: get_weather() success, space encoding, and error paths.""" - - async def test_success_returns_status_and_parsed(self): - """Successful call returns status and parsed weather JSON.""" - payload = {"temperature": {"value": 15, "unit": "C"}} - api = _make_api(status=200, json_data=payload) - - result = await api.get_weather("?lat=51.5&lon=-0.1") - - assert result["original"] == 200 - assert result["parsed"] == payload - - async def test_space_in_weather_url_is_encoded(self): - """Spaces in the weather_url are replaced with %20.""" - api = _make_api(status=200, json_data={}) - - captured_url = [] - original_request = api.request - - async def capture_request(method, url, **kwargs): - captured_url.append(url) - return await original_request(method, url, **kwargs) - - with patch.object(api, "request", side_effect=capture_request): - await api.get_weather("?postcode=SW1A 2AA") - - assert len(captured_url) == 1 - assert " " not in captured_url[0] - assert "%20" in captured_url[0] - - async def test_url_is_prefixed_with_weather_base(self): - """The weather base URL is prepended to the given weather_url.""" - api = _make_api(status=200, json_data={}) - - captured_url = [] - original_request = api.request - - async def capture_request(method, url, **kwargs): - captured_url.append(url) - return await original_request(method, url, **kwargs) - - with patch.object(api, "request", side_effect=capture_request): - await api.get_weather("?lat=51.5") - - assert captured_url[0].startswith("https://weather.prod.bgchprod.info/weather") - - async def test_os_error_raises_http_error(self): - """OSError inside the try block causes error() → HTTPError.""" - api = _make_api(status=200) - api.websession.request.side_effect = OSError("network fail") - with pytest.raises(web_exceptions.HTTPError): - await api.get_weather("?lat=51.5") - - async def test_runtime_error_raises_http_error(self): - """RuntimeError inside the try block causes error() → HTTPError.""" - api = _make_api(status=200) - api.websession.request.side_effect = RuntimeError("unexpected") - with pytest.raises(web_exceptions.HTTPError): - await api.get_weather("?lat=51.5") - - async def test_zero_division_raises_http_error(self): - """ZeroDivisionError inside the try block causes error() → HTTPError.""" - api = _make_api(status=200) - api.websession.request.side_effect = ZeroDivisionError() - with pytest.raises(web_exceptions.HTTPError): - await api.get_weather("?lat=51.5") - - async def test_connection_error_raises_http_error(self): - """ConnectionError inside the try block causes error() → HTTPError.""" - api = _make_api(status=200) - api.websession.request.side_effect = ConnectionError("disconnected") - with pytest.raises(web_exceptions.HTTPError): - await api.get_weather("?lat=51.5") - - -# --------------------------------------------------------------------------- -# Tests: set_state() JSON encoding — Fix A -# --------------------------------------------------------------------------- - - -class TestSetStateJsonEncoding: - """set_state must produce valid JSON even when kwarg values contain special characters.""" - - async def test_set_state_escapes_quotes_in_value(self): - """A value containing double-quotes must produce valid, parseable JSON.""" - import json # noqa: PLC0415 - - session = MagicMock() - session.tokens.token_data = {"token": "tok"} - session.config.file = False - api = HiveApiAsync(hive_session=session) - api.urls = {"nodes": "https://beekeeper.hivehome.com/1.0/nodes/{}/{}"} - - captured = {} - - async def fake_request(_method, _url, **kwargs): - captured["data"] = kwargs.get("data") - resp = MagicMock() - resp.status = 200 - resp.json = AsyncMock(return_value={}) - return resp - - with patch.object(api, "request", side_effect=fake_request): - with patch.object(api, "is_file_being_used", new=AsyncMock()): - await api.set_state("heating", "node-1", mode='MANUAL"injected') - - parsed = json.loads(captured["data"]) - assert parsed["mode"] == 'MANUAL"injected' diff --git a/tests/unit/test_hive_auth_async.py b/tests/unit/test_hive_auth_async.py index fa6939b..bb82643 100644 --- a/tests/unit/test_hive_auth_async.py +++ b/tests/unit/test_hive_auth_async.py @@ -14,6 +14,7 @@ HiveInvalidPassword, HiveInvalidUsername, HiveRefreshTokenExpired, + HiveUnknownConfiguration, ) # --------------------------------------------------------------------------- @@ -73,6 +74,13 @@ async def _make_auth( return auth +_LOGIN_INFO = { + "UPID": "eu-west-1_TestPool", + "CLIID": "test-client-id", + "REGION": "eu-west-1_TestPool", +} + + # --------------------------------------------------------------------------- # Tests: __init__ # --------------------------------------------------------------------------- @@ -539,3 +547,840 @@ async def test_endpoint_error_raises_api_error(self): auth.loop.run_in_executor.side_effect = _endpoint_error() with pytest.raises(HiveApiError): await auth.refresh_token("tok") + + +# --------------------------------------------------------------------------- +# Migrated from test_hive_auth_async_extended.py +# --------------------------------------------------------------------------- + + +class TestAsyncInit: + """Cover lines 98-112: async_init() sets pool_id, client_id, region and boto3 client.""" + + async def test_async_init_sets_pool_id_and_client_id(self): + """async_init reads login info and sets internal auth fields.""" + from apyhiveapi.api.hive_auth_async import HiveAuthAsync + + auth = HiveAuthAsync(username="user@test.com", password="pass") + auth.client = None + + mock_boto_client = MagicMock() + mock_loop = MagicMock() + mock_loop.run_in_executor = AsyncMock( + side_effect=[_LOGIN_INFO, mock_boto_client] + ) + + with patch("asyncio.get_running_loop", return_value=mock_loop): + await auth.async_init() + + assert auth._pool_id == "eu-west-1_TestPool" + assert auth._client_id == "test-client-id" + assert auth._region == "eu-west-1" + assert auth.client is mock_boto_client + + async def test_async_init_splits_region_correctly(self): + """Region is extracted as the part before the underscore in UPID/REGION.""" + from apyhiveapi.api.hive_auth_async import HiveAuthAsync + + auth = HiveAuthAsync(username="user@test.com", password="pass") + auth.client = None + + login_info = { + "UPID": "ap-southeast-2_XyzPool", + "CLIID": "ap-client", + "REGION": "ap-southeast-2_XyzPool", + } + mock_boto_client = MagicMock() + mock_loop = MagicMock() + mock_loop.run_in_executor = AsyncMock( + side_effect=[login_info, mock_boto_client] + ) + + with patch("asyncio.get_running_loop", return_value=mock_loop): + await auth.async_init() + + assert auth._region == "ap-southeast-2" + + +class TestCalculateA: + """Cover line 141: safety check when big_a % big_n == 0.""" + + async def test_safety_check_raises_when_a_is_zero_mod_n(self): + """If pow(g, a, n) == 0 mod n (i.e., equals big_n or 0), ValueError is raised.""" + auth = await _make_auth() + with patch("builtins.pow", return_value=auth.big_n): + with pytest.raises(ValueError, match="Safety check for A failed"): + auth.calculate_a() + + async def test_safety_check_passes_normally(self): + """Under normal random inputs, calculate_a does not raise and returns positive int.""" + auth = await _make_auth() + result = auth.calculate_a() + assert result > 0 + + +class TestGetPasswordAuthenticationKey: + """Cover lines 155-172: get_password_authentication_key() computes HKDF.""" + + async def test_returns_bytes(self): + """With valid SRP inputs, the method returns a bytes-like value.""" + auth = await _make_auth() + from apyhiveapi.api.srp_crypto import get_random + + server_b_value = hex(get_random(128))[2:] + salt = hex(get_random(16))[2:] + + with patch("apyhiveapi.api.hive_auth_async.calculate_u", return_value=99999): + result = auth.get_password_authentication_key( + "testuser", "testpass", server_b_value, salt + ) + + assert isinstance(result, (bytes, bytearray)) + + async def test_u_value_zero_raises_value_error(self): + """If calculate_u returns 0, ValueError is raised.""" + auth = await _make_auth() + from apyhiveapi.api.srp_crypto import get_random + + server_b_value = hex(get_random(128))[2:] + salt = hex(get_random(16))[2:] + + with patch("apyhiveapi.api.hive_auth_async.calculate_u", return_value=0): + with pytest.raises(ValueError, match="U cannot be zero"): + auth.get_password_authentication_key( + "testuser", "testpass", server_b_value, salt + ) + + async def test_accepts_integer_server_b(self): + """server_b_value can be passed as an integer (handled by _to_int).""" + auth = await _make_auth() + from apyhiveapi.api.srp_crypto import get_random + + server_b_int = get_random(128) + salt = hex(get_random(16))[2:] + + with patch("apyhiveapi.api.hive_auth_async.calculate_u", return_value=12345): + result = auth.get_password_authentication_key( + "testuser", "testpass", server_b_int, salt + ) + + assert isinstance(result, (bytes, bytearray)) + + +class TestProcessChallenge: + """Cover lines 205-254: process_challenge() builds the SRP response.""" + + def _make_challenge_params(self, salt_as_int=False): + import base64 + + salt = "aabbccddee" + if salt_as_int: + salt = int("aabbccddee", 16) + return { + "USER_ID_FOR_SRP": "challenge-user@test.com", + "SALT": salt, + "SRP_B": "ff" * 32, + "SECRET_BLOCK": base64.b64encode(b"secret-block-bytes").decode(), + } + + async def test_returns_required_keys(self): + """Basic challenge response includes mandatory SRP keys.""" + auth = await _make_auth() + fake_hkdf = b"\x00" * 32 + auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) + params = self._make_challenge_params() + result = await auth.process_challenge(params) + assert "TIMESTAMP" in result + assert "USERNAME" in result + assert "PASSWORD_CLAIM_SECRET_BLOCK" in result + assert "PASSWORD_CLAIM_SIGNATURE" in result + + async def test_sets_user_id_from_challenge(self): + """process_challenge stores USER_ID_FOR_SRP as self.user_id.""" + auth = await _make_auth() + fake_hkdf = b"\x00" * 32 + auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) + params = self._make_challenge_params() + await auth.process_challenge(params) + assert auth.user_id == "challenge-user@test.com" + + async def test_with_client_secret_adds_secret_hash(self): + """When client_secret is set, SECRET_HASH is added to the response.""" + auth = await _make_auth(client_secret="my-secret") + fake_hkdf = b"\x00" * 32 + auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) + params = self._make_challenge_params() + result = await auth.process_challenge(params) + assert "SECRET_HASH" in result + + async def test_without_client_secret_no_secret_hash(self): + """When client_secret is None, SECRET_HASH is absent from the response.""" + auth = await _make_auth(client_secret=None) + fake_hkdf = b"\x00" * 32 + auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) + params = self._make_challenge_params() + result = await auth.process_challenge(params) + assert "SECRET_HASH" not in result + + async def test_with_device_key_adds_device_key(self): + """When device_key is set, DEVICE_KEY is added to the response.""" + auth = await _make_auth(device_key="dk-challenge") + fake_hkdf = b"\x00" * 32 + auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) + params = self._make_challenge_params() + result = await auth.process_challenge(params) + assert result["DEVICE_KEY"] == "dk-challenge" + + async def test_without_device_key_no_device_key_in_response(self): + """When device_key is None, DEVICE_KEY is absent from the response.""" + auth = await _make_auth(device_key=None) + fake_hkdf = b"\x00" * 32 + auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) + params = self._make_challenge_params() + result = await auth.process_challenge(params) + assert "DEVICE_KEY" not in result + + async def test_salt_as_integer_triggers_pad_hex(self): + """When SALT is an integer (not str), pad_hex is applied before use.""" + auth = await _make_auth() + fake_hkdf = b"\x00" * 32 + auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) + params = self._make_challenge_params(salt_as_int=True) + result = await auth.process_challenge(params) + assert "TIMESTAMP" in result + + +class TestLoginClientNone: + """Cover line 263: when client is None, async_init() is awaited.""" + + async def test_login_calls_async_init_when_client_is_none(self): + """If client is None before login, async_init is called before SRP flow.""" + auth = await _make_auth() + auth.client = None + auth.use_file = False + + auth_result = {"AuthenticationResult": {"AccessToken": "post-init-token"}} + challenge_response = { + "ChallengeName": "PASSWORD_VERIFIER", + "ChallengeParameters": { + "USER_ID_FOR_SRP": "user@test.com", + "SALT": "aabbccdd", + "SRP_B": "ccddee", + "SECRET_BLOCK": "YWJj", + }, + } + + async def fake_async_init(): + auth.client = MagicMock() + auth._client_id = "test-client-id" + auth._pool_id = "eu-west-1_TestPool" + auth._region = "eu-west-1" + + with patch.object(auth, "async_init", side_effect=fake_async_init) as mock_init: + with patch.object( + auth, "process_challenge", new_callable=AsyncMock + ) as mock_ch: + mock_ch.return_value = { + "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", + "USERNAME": "user", + } + auth.loop.run_in_executor = AsyncMock( + side_effect=[challenge_response, auth_result] + ) + result = await auth.login() + + mock_init.assert_called_once() + assert "AuthenticationResult" in result + + +class TestLoginUnsupportedChallenge: + """Cover lines 335-337: non-PASSWORD_VERIFIER challenge raises NotImplementedError.""" + + async def test_new_password_required_raises_not_implemented(self): + """NEW_PASSWORD_REQUIRED challenge is not supported and raises NotImplementedError.""" + auth = await _make_auth() + auth.loop.run_in_executor = AsyncMock( + return_value={ + "ChallengeName": "NEW_PASSWORD_REQUIRED", + "ChallengeParameters": {}, + } + ) + with pytest.raises(NotImplementedError, match="NEW_PASSWORD_REQUIRED"): + await auth.login() + + async def test_custom_challenge_raises_not_implemented(self): + """Any unknown challenge name raises NotImplementedError.""" + auth = await _make_auth() + auth.loop.run_in_executor = AsyncMock( + return_value={ + "ChallengeName": "UNKNOWN_CHALLENGE_TYPE", + "ChallengeParameters": {}, + } + ) + with pytest.raises(NotImplementedError, match="UNKNOWN_CHALLENGE_TYPE"): + await auth.login() + + +class TestLoginResourceNotFound: + """Cover lines 307-311: ResourceNotFoundException in respond_to_auth_challenge.""" + + async def test_resource_not_found_raises_invalid_device_authentication(self): + """ResourceNotFoundException during challenge → HiveInvalidDeviceAuthentication.""" + auth = await _make_auth() + challenge_response = { + "ChallengeName": "PASSWORD_VERIFIER", + "ChallengeParameters": { + "USER_ID_FOR_SRP": "user@test.com", + "SALT": "aabbccdd", + "SRP_B": "ccddee", + "SECRET_BLOCK": "YWJj", + }, + } + resource_err = _named_client_error("ResourceNotFoundException") + with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: + mock_ch.return_value = { + "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", + "USERNAME": "user", + } + auth.loop.run_in_executor = AsyncMock( + side_effect=[challenge_response, resource_err] + ) + with pytest.raises(HiveInvalidDeviceAuthentication): + await auth.login() + + +class TestLoginEndpointErrorOnChallenge: + """Cover lines 312-317: EndpointConnectionError during respond_to_auth_challenge.""" + + async def test_endpoint_error_on_challenge_raises_api_error(self): + """EndpointConnectionError during SRP challenge response → HiveApiError.""" + auth = await _make_auth() + challenge_response = { + "ChallengeName": "PASSWORD_VERIFIER", + "ChallengeParameters": { + "USER_ID_FOR_SRP": "user@test.com", + "SALT": "aabbccdd", + "SRP_B": "ccddee", + "SECRET_BLOCK": "YWJj", + }, + } + with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: + mock_ch.return_value = { + "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", + "USERNAME": "user", + } + auth.loop.run_in_executor = AsyncMock( + side_effect=[challenge_response, _endpoint_error()] + ) + with pytest.raises(HiveApiError): + await auth.login() + + +class TestLoginResultHandling: + """Cover lines 321-333: AuthenticationResult presence/absence in login result.""" + + async def test_result_without_authentication_result_does_not_store_token(self): + """If result lacks 'AuthenticationResult', access_token is not set.""" + auth = await _make_auth() + challenge_response = { + "ChallengeName": "PASSWORD_VERIFIER", + "ChallengeParameters": { + "USER_ID_FOR_SRP": "user@test.com", + "SALT": "aabbccdd", + "SRP_B": "ccddee", + "SECRET_BLOCK": "YWJj", + }, + } + sms_challenge_result = { + "ChallengeName": "SMS_MFA", + "Session": "session-tok", + "ChallengeParameters": {}, + } + with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: + mock_ch.return_value = { + "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", + "USERNAME": "user", + } + auth.loop.run_in_executor = AsyncMock( + side_effect=[challenge_response, sms_challenge_result] + ) + result = await auth.login() + + assert auth.access_token is None + assert result is sms_challenge_result + + async def test_result_with_authentication_result_but_no_new_device_metadata(self): + """AuthenticationResult without NewDeviceMetadata sets access_token only.""" + auth = await _make_auth() + challenge_response = { + "ChallengeName": "PASSWORD_VERIFIER", + "ChallengeParameters": { + "USER_ID_FOR_SRP": "user@test.com", + "SALT": "aabbccdd", + "SRP_B": "ccddee", + "SECRET_BLOCK": "YWJj", + }, + } + auth_result = {"AuthenticationResult": {"AccessToken": "my-access-token"}} + with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: + mock_ch.return_value = { + "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", + "USERNAME": "user", + } + auth.loop.run_in_executor = AsyncMock( + side_effect=[challenge_response, auth_result] + ) + await auth.login() + + assert auth.access_token == "my-access-token" + assert auth.device_group_key is None + assert auth.device_key is None + + +class TestDeviceLoginClientNone: + """Cover device_login's async_init call when client is None.""" + + async def test_device_login_calls_async_init_when_client_is_none(self): + """If client is None, async_init is called before proceeding.""" + auth = await _make_auth(device_key="dk-1") + auth.client = None + + async def fake_async_init(): + auth.client = MagicMock() + auth._client_id = "test-client-id" + + with patch.object(auth, "async_init", side_effect=fake_async_init) as mock_init: + auth.loop.run_in_executor = AsyncMock( + side_effect=_named_client_error("ResourceNotFoundException") + ) + with pytest.raises(HiveInvalidDeviceAuthentication): + await auth.device_login() + + mock_init.assert_called_once() + + +class TestSms2faNoNewDeviceMetadata: + """Cover line 441: sms_2fa when NewDeviceMetadata is absent in result.""" + + async def test_no_new_device_metadata_does_not_set_device_keys(self): + """When NewDeviceMetadata is absent, device_group_key and device_key stay None.""" + auth = await _make_auth() + original_group_key = auth.device_group_key + original_device_key = auth.device_key + + sms_result = { + "AuthenticationResult": { + "AccessToken": "sms-access-token", + } + } + auth.loop.run_in_executor = AsyncMock(return_value=sms_result) + + result = await auth.sms_2fa("654321", {"Session": "sess-abc"}) + + assert auth.access_token == "sms-access-token" + assert auth.device_group_key == original_group_key + assert auth.device_key == original_device_key + assert result is sms_result + + +class TestSms2faCodeMismatch: + """Cover lines 424-429: CodeMismatchException raises HiveInvalid2FACode.""" + + async def test_code_mismatch_raises_invalid_2fa_code(self): + """CodeMismatchException in sms_2fa raises HiveInvalid2FACode.""" + auth = await _make_auth() + auth.loop.run_in_executor = AsyncMock( + side_effect=_named_client_error("CodeMismatchException") + ) + with pytest.raises(HiveInvalid2FACode): + await auth.sms_2fa("000000", {"Session": "sess-1"}) + + async def test_not_authorized_raises_invalid_2fa_code(self): + """NotAuthorizedException in sms_2fa raises HiveInvalid2FACode.""" + auth = await _make_auth() + auth.loop.run_in_executor = AsyncMock( + side_effect=_named_client_error("NotAuthorizedException") + ) + with pytest.raises(HiveInvalid2FACode): + await auth.sms_2fa("111111", {"Session": "sess-2"}) + + +class TestRefreshTokenClientNone: + """Cover line 440-441: refresh_token calls async_init when client is None.""" + + async def test_refresh_token_calls_async_init_when_client_is_none(self): + """If client is None, async_init is awaited before refreshing.""" + auth = await _make_auth() + auth.client = None + + result_payload = {"AuthenticationResult": {"AccessToken": "refreshed-tok"}} + + async def fake_async_init(): + auth.client = MagicMock() + + with patch.object(auth, "async_init", side_effect=fake_async_init) as mock_init: + auth.loop.run_in_executor = AsyncMock(return_value=result_payload) + result = await auth.refresh_token("some-refresh-token") + + mock_init.assert_called_once() + assert result is result_payload + + +class TestRefreshTokenResultPath: + """Cover lines 479-485: refresh_token when result is returned normally.""" + + async def test_returns_result_directly(self): + """refresh_token returns the result from Cognito directly.""" + auth = await _make_auth() + result_payload = {"AuthenticationResult": {"AccessToken": "tok-xyz"}} + auth.loop.run_in_executor = AsyncMock(return_value=result_payload) + result = await auth.refresh_token("refresh-tok-abc") + assert result is result_payload + + async def test_with_device_key_includes_device_key_param(self): + """When device_key is set, DEVICE_KEY is included in auth_params.""" + auth = await _make_auth(device_key="dk-refresh-001") + result_payload = {"AuthenticationResult": {"AccessToken": "tok-dk"}} + auth.loop.run_in_executor = AsyncMock(return_value=result_payload) + result = await auth.refresh_token("refresh-tok-dk") + assert result is result_payload + + async def test_without_device_key_sends_only_refresh_token(self): + """When device_key is None, auth_params has only REFRESH_TOKEN.""" + auth = await _make_auth(device_key=None) + result_payload = {"AuthenticationResult": {"AccessToken": "tok-no-dk"}} + auth.loop.run_in_executor = AsyncMock(return_value=result_payload) + result = await auth.refresh_token("refresh-tok-no-dk") + assert result is result_payload + + +class TestLoginInitiateAuthSwallowedClientError: + """Arc 280->288: ClientError caught but class name is not UserNotFoundException.""" + + async def test_other_client_error_in_initiate_auth_falls_through(self): + """Non-UserNotFoundException ClientError raises HiveApiError.""" + auth = await _make_auth() + + wrong_cls = type("SomeOtherError", (botocore.exceptions.ClientError,), {}) + wrong_err = wrong_cls( + {"Error": {"Code": "SomeOtherError", "Message": "msg"}}, "op" + ) + auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) + + with pytest.raises(HiveApiError): + await auth.login() + + +class TestLoginInitiateAuthSwallowedEndpointError: + """EndpointConnectionError in initiate_auth always raises HiveApiError.""" + + async def test_wrong_name_endpoint_error_in_initiate_auth_raises_api_error(self): + """Any EndpointConnectionError subclass in initiate_auth raises HiveApiError.""" + auth = await _make_auth() + + wrong_cls = type( + "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} + ) + wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") + auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) + + with pytest.raises(HiveApiError): + await auth.login() + + +class TestLoginChallengeSwallowedClientError: + """Arc 307->319: ClientError caught in challenge response with name not matching.""" + + async def test_other_client_error_in_challenge_falls_through(self): + """ClientError that is neither NotAuthorized nor ResourceNotFound raises HiveApiError.""" + auth = await _make_auth() + + challenge_response = { + "ChallengeName": "PASSWORD_VERIFIER", + "ChallengeParameters": { + "USER_ID_FOR_SRP": "user@test.com", + "SALT": "aabbccdd", + "SRP_B": "ccddee", + "SECRET_BLOCK": "YWJj", + }, + } + + wrong_cls = type("ThirdPartyError", (botocore.exceptions.ClientError,), {}) + wrong_err = wrong_cls( + {"Error": {"Code": "ThirdPartyError", "Message": "msg"}}, "op" + ) + + with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: + mock_ch.return_value = {"TIMESTAMP": "...", "USERNAME": "user"} + auth.loop.run_in_executor = AsyncMock( + side_effect=[challenge_response, wrong_err] + ) + with pytest.raises(HiveApiError): + await auth.login() + + +class TestLoginChallengeSwallowedEndpointError: + """EndpointConnectionError in respond_to_auth_challenge always raises HiveApiError.""" + + async def test_wrong_name_endpoint_error_in_challenge_raises_api_error(self): + """Any EndpointConnectionError subclass in SRP challenge raises HiveApiError.""" + auth = await _make_auth() + + challenge_response = { + "ChallengeName": "PASSWORD_VERIFIER", + "ChallengeParameters": { + "USER_ID_FOR_SRP": "user@test.com", + "SALT": "aabbccdd", + "SRP_B": "ccddee", + "SECRET_BLOCK": "YWJj", + }, + } + + wrong_cls = type( + "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} + ) + wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") + + with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: + mock_ch.return_value = {"TIMESTAMP": "...", "USERNAME": "user"} + auth.loop.run_in_executor = AsyncMock( + side_effect=[challenge_response, wrong_err] + ) + with pytest.raises(HiveApiError): + await auth.login() + + +class TestDeviceLoginSuccessPath: + """Lines 364-367, 391: device_login processes device challenge and returns result.""" + + async def test_successful_device_login_returns_auth_result(self): + """Full device_login success: process_device_challenge called, result returned.""" + auth = await _make_auth(device_key="dk-abc", device_group_key="grp-abc") + auth.device_password = "dev-pass" # pragma: allowlist secret + + initial_result = { + "ChallengeParameters": { + "USERNAME": "user@test.com", + "SALT": "aabbccdd", + "SRP_B": "ccddee", + "SECRET_BLOCK": "YWJj", + } + } + final_result = {"AuthenticationResult": {"AccessToken": "device-access-token"}} + + with patch.object( + auth, "process_device_challenge", new_callable=AsyncMock + ) as mock_pdc: + mock_pdc.return_value = { + "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", + "USERNAME": "user@test.com", + "PASSWORD_CLAIM_SECRET_BLOCK": "YWJj", # pragma: allowlist secret + "PASSWORD_CLAIM_SIGNATURE": "sig", # pragma: allowlist secret + "DEVICE_KEY": "dk-abc", + } + auth.loop.run_in_executor = AsyncMock( + side_effect=[initial_result, final_result] + ) + result = await auth.device_login() + + mock_pdc.assert_called_once_with(initial_result["ChallengeParameters"]) + assert result is final_result + + async def test_device_login_calls_second_respond_to_auth_challenge(self): + """Lines 367-375: second respond_to_auth_challenge is called with device challenge.""" + auth = await _make_auth(device_key="dk-xyz", device_group_key="grp-xyz") + auth.device_password = "dev-pass-xyz" # pragma: allowlist secret + + initial_result = { + "ChallengeParameters": { + "USERNAME": "user@test.com", + "SALT": "11223344", + "SRP_B": "55667788", + "SECRET_BLOCK": "dGVzdA==", # pragma: allowlist secret + } + } + final_result = {"AuthenticationResult": {"AccessToken": "tok-xyz"}} + + challenge_resp = { + "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", + "USERNAME": "user@test.com", + "PASSWORD_CLAIM_SECRET_BLOCK": "dGVzdA==", # pragma: allowlist secret + "PASSWORD_CLAIM_SIGNATURE": "sig", # pragma: allowlist secret + "DEVICE_KEY": "dk-xyz", + } + + with patch.object( + auth, "process_device_challenge", new_callable=AsyncMock + ) as mock_pdc: + mock_pdc.return_value = challenge_resp + auth.loop.run_in_executor = AsyncMock( + side_effect=[initial_result, final_result] + ) + result = await auth.device_login() + + assert auth.loop.run_in_executor.call_count == 2 + assert result["AuthenticationResult"]["AccessToken"] == "tok-xyz" + + +class TestDeviceLoginEndpointWrongName: + """Any EndpointConnectionError in device_login always raises HiveApiError.""" + + async def test_wrong_name_endpoint_error_raises_api_error(self): + """Any EndpointConnectionError subclass in device_login raises HiveApiError.""" + auth = await _make_auth(device_key="dk-err", device_group_key="grp-err") + auth.device_password = "dev-pass-err" # pragma: allowlist secret + + wrong_cls = type( + "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} + ) + wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") + auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) + + with pytest.raises(HiveApiError): + await auth.device_login() + + +class TestSms2faSwallowedClientError: + """Arc 424->435: ClientError caught in sms_2fa with unrecognised class name.""" + + async def test_other_client_error_is_swallowed_returns_none(self): + """Non-matching ClientError is swallowed; result stays None (returned).""" + auth = await _make_auth() + + wrong_cls = type("OtherError", (botocore.exceptions.ClientError,), {}) + wrong_err = wrong_cls({"Error": {"Code": "OtherError", "Message": "msg"}}, "op") + auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) + + result = await auth.sms_2fa("123456", {"Session": "sess-xyz"}) + assert result is None + + +class TestSms2faSwallowedEndpointError: + """Any EndpointConnectionError in sms_2fa raises HiveApiError.""" + + async def test_wrong_name_endpoint_error_raises_api_error(self): + """Any EndpointConnectionError subclass in sms_2fa raises HiveApiError.""" + auth = await _make_auth() + + wrong_cls = type( + "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} + ) + wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") + auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) + + with pytest.raises(HiveApiError): + await auth.sms_2fa("654321", {"Session": "sess-abc"}) + + +class TestRefreshTokenSwallowedEndpointError: + """Any EndpointConnectionError in refresh_token raises HiveApiError.""" + + async def test_wrong_name_endpoint_error_raises_api_error(self): + """Any EndpointConnectionError subclass in refresh_token raises HiveApiError.""" + auth = await _make_auth() + + wrong_cls = type( + "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} + ) + wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") + auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) + + with pytest.raises(HiveApiError): + await auth.refresh_token("some-refresh-token") + + +class TestAsyncInitMissingKeys: + """async_init must raise HiveUnknownConfiguration when login info keys are absent.""" + + async def test_async_init_missing_region_raises_configuration_error(self): + """If REGION is absent from login info, raise HiveUnknownConfiguration.""" + from apyhiveapi.api.hive_auth_async import HiveAuthAsync + + auth = HiveAuthAsync(username="user@test.com", password="pass") + bad_login_info = {"UPID": "eu-west-1_TestPool", "CLIID": "test-client-id"} + mock_loop = MagicMock() + mock_loop.run_in_executor = AsyncMock(side_effect=[bad_login_info]) + with patch("asyncio.get_running_loop", return_value=mock_loop): + with pytest.raises(HiveUnknownConfiguration): + await auth.async_init() + + async def test_async_init_missing_upid_raises_configuration_error(self): + """If UPID is absent from login info, raise HiveUnknownConfiguration.""" + from apyhiveapi.api.hive_auth_async import HiveAuthAsync + + auth = HiveAuthAsync(username="user@test.com", password="pass") + bad_login_info = {"CLIID": "test-client-id", "REGION": "eu-west-1_TestPool"} + mock_loop = MagicMock() + mock_loop.run_in_executor = AsyncMock(side_effect=[bad_login_info]) + with patch("asyncio.get_running_loop", return_value=mock_loop): + with pytest.raises(HiveUnknownConfiguration): + await auth.async_init() + + +class TestGetPasswordAuthKeyNonePoolId: + """get_password_authentication_key must not crash with AttributeError when _pool_id is None.""" + + async def test_none_pool_id_raises_configuration_error(self): + """If _pool_id is None, raise HiveUnknownConfiguration (not AttributeError).""" + auth = await _make_auth() + auth._pool_id = None + with pytest.raises(HiveUnknownConfiguration): + auth.get_password_authentication_key("user", "pass", "DEADBEEF", "ABCDEF") + + +class TestLoginUnhandledClientError: + """Unhandled ClientError codes in login() must raise HiveApiError, not crash.""" + + async def test_initiate_auth_unhandled_error_raises_hive_api_error(self): + """Non-UserNotFoundException ClientError from initiate_auth raises HiveApiError.""" + auth = await _make_auth() + err = botocore.exceptions.ClientError( + {"Error": {"Code": "TooManyRequestsException", "Message": "too many"}}, + "InitiateAuth", + ) + auth.loop.run_in_executor = AsyncMock(side_effect=err) + with pytest.raises(HiveApiError): + await auth.login() + + async def test_respond_to_challenge_unhandled_error_raises_hive_api_error(self): + """Non-NotAuthorized/ResourceNotFound ClientError raises HiveApiError.""" + auth = await _make_auth() + challenge_response = { + "ChallengeName": "PASSWORD_VERIFIER", + "ChallengeParameters": { + "USER_ID_FOR_SRP": "user@test.com", + "SALT": "aabbccdd", + "SRP_B": "ccddee", + "SECRET_BLOCK": "YWJj", + }, + } + respond_err = botocore.exceptions.ClientError( + {"Error": {"Code": "InternalErrorException", "Message": "internal"}}, + "RespondToAuthChallenge", + ) + auth.loop.run_in_executor = AsyncMock( + side_effect=[challenge_response, respond_err] + ) + auth.process_challenge = AsyncMock( + return_value={"TIMESTAMP": "t", "USERNAME": "u"} + ) + with pytest.raises(HiveApiError): + await auth.login() + + +class TestAsyncInitNoneGuard: + """async_init must guard against get_login_info returning None.""" + + async def test_async_init_raises_when_login_info_is_none(self): + """async_init raises HiveUnknownConfiguration when get_login_info returns None.""" + from apyhiveapi.api.hive_auth_async import HiveAuthAsync + + auth = HiveAuthAsync(username="user@test.com", password="pass") + auth.client = None + + mock_loop = MagicMock() + mock_loop.run_in_executor = AsyncMock(return_value=None) + + with patch("asyncio.get_running_loop", return_value=mock_loop): + with pytest.raises(HiveUnknownConfiguration): + await auth.async_init() diff --git a/tests/unit/test_hive_auth_async_extended.py b/tests/unit/test_hive_auth_async_extended.py deleted file mode 100644 index e56ee8b..0000000 --- a/tests/unit/test_hive_auth_async_extended.py +++ /dev/null @@ -1,1089 +0,0 @@ -"""Extended unit tests for HiveAuthAsync — covers previously uncovered paths.""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock, patch - -import botocore.exceptions -import pytest -from apyhiveapi.helper.hive_exceptions import ( - HiveApiError, - HiveInvalid2FACode, - HiveInvalidDeviceAuthentication, -) - -# --------------------------------------------------------------------------- -# Exception factories (same pattern as test_hive_auth_async.py) -# --------------------------------------------------------------------------- - - -def _named_client_error( - code: str, message: str = "" -) -> botocore.exceptions.ClientError: - """Return a ClientError whose __class__.__name__ matches ``code``.""" - cls = type(code, (botocore.exceptions.ClientError,), {}) - return cls( - {"Error": {"Code": code, "Message": message}}, - "operation", - ) - - -def _endpoint_error() -> botocore.exceptions.EndpointConnectionError: - return botocore.exceptions.EndpointConnectionError( - endpoint_url="https://cognito.eu-west-1.amazonaws.com" - ) - - -# --------------------------------------------------------------------------- -# Shared factory -# --------------------------------------------------------------------------- - -_LOGIN_INFO = { - "UPID": "eu-west-1_TestPool", - "CLIID": "test-client-id", - "REGION": "eu-west-1_TestPool", -} - - -async def _make_auth( - username: str = "user@test.com", - password: str = "testpass", - device_key: str | None = None, - device_group_key: str | None = None, - device_password: str | None = None, - client_secret: str | None = None, -): - from apyhiveapi.api.hive_auth_async import HiveAuthAsync - - auth = HiveAuthAsync( - username=username, - password=password, - device_key=device_key, - device_group_key=device_group_key, - device_password=device_password, - client_secret=client_secret, - ) - # Bypass async_init — inject mocked internals directly. - auth.client = MagicMock() - auth._client_id = "test-client-id" - auth._pool_id = "eu-west-1_TestPool" - auth._region = "eu-west-1" - auth.loop = MagicMock() - auth.loop.run_in_executor = AsyncMock() - return auth - - -# --------------------------------------------------------------------------- -# Tests: async_init() — lines 96-112 -# --------------------------------------------------------------------------- - - -class TestAsyncInit: - """Cover lines 98-112: async_init() sets pool_id, client_id, region and - boto3 client.""" - - async def test_async_init_sets_pool_id_and_client_id(self): - """async_init reads login info and sets internal auth fields.""" - from apyhiveapi.api.hive_auth_async import HiveAuthAsync - - auth = HiveAuthAsync(username="user@test.com", password="pass") - auth.client = None # trigger async_init flow - - mock_boto_client = MagicMock() - mock_loop = MagicMock() - mock_loop.run_in_executor = AsyncMock( - side_effect=[_LOGIN_INFO, mock_boto_client] - ) - - with patch("asyncio.get_running_loop", return_value=mock_loop): - await auth.async_init() - - assert auth._pool_id == "eu-west-1_TestPool" - assert auth._client_id == "test-client-id" - assert auth._region == "eu-west-1" - assert auth.client is mock_boto_client - - async def test_async_init_splits_region_correctly(self): - """Region is extracted as the part before the underscore in UPID/REGION.""" - from apyhiveapi.api.hive_auth_async import HiveAuthAsync - - auth = HiveAuthAsync(username="user@test.com", password="pass") - auth.client = None - - login_info = { - "UPID": "ap-southeast-2_XyzPool", - "CLIID": "ap-client", - "REGION": "ap-southeast-2_XyzPool", - } - mock_boto_client = MagicMock() - mock_loop = MagicMock() - mock_loop.run_in_executor = AsyncMock( - side_effect=[login_info, mock_boto_client] - ) - - with patch("asyncio.get_running_loop", return_value=mock_loop): - await auth.async_init() - - assert auth._region == "ap-southeast-2" - - -# --------------------------------------------------------------------------- -# Tests: calculate_a() safety check — line 140-141 -# --------------------------------------------------------------------------- - - -class TestCalculateA: - """Cover line 141: safety check when big_a % big_n == 0.""" - - async def test_safety_check_raises_when_a_is_zero_mod_n(self): - """If pow(g, a, n) == 0 mod n (i.e., equals big_n or 0), ValueError is raised.""" - auth = await _make_auth() - # Force pow to return auth.big_n so that big_a % big_n == 0 - with patch("builtins.pow", return_value=auth.big_n): - with pytest.raises(ValueError, match="Safety check for A failed"): - auth.calculate_a() - - async def test_safety_check_passes_normally(self): - """Under normal random inputs, calculate_a does not raise and returns positive int.""" - auth = await _make_auth() - # calculate_a was already called during __init__; calling it again should also work - result = auth.calculate_a() - assert result > 0 - - -# --------------------------------------------------------------------------- -# Tests: get_password_authentication_key() — lines 155-172 -# --------------------------------------------------------------------------- - - -class TestGetPasswordAuthenticationKey: - """Cover lines 155-172: get_password_authentication_key() computes HKDF.""" - - async def test_returns_bytes(self): - """With valid SRP inputs, the method returns a bytes-like value.""" - auth = await _make_auth() - from apyhiveapi.api.srp_crypto import get_random - - # Pick a server_b that won't produce u_value == 0 by using a known large value - server_b_value = hex(get_random(128))[2:] - salt = hex(get_random(16))[2:] - - # Patch calculate_u to return a known non-zero value to avoid flakiness - with patch("apyhiveapi.api.hive_auth_async.calculate_u", return_value=99999): - result = auth.get_password_authentication_key( - "testuser", "testpass", server_b_value, salt - ) - - assert isinstance(result, (bytes, bytearray)) - - async def test_u_value_zero_raises_value_error(self): - """If calculate_u returns 0, ValueError is raised.""" - auth = await _make_auth() - from apyhiveapi.api.srp_crypto import get_random - - server_b_value = hex(get_random(128))[2:] - salt = hex(get_random(16))[2:] - - with patch("apyhiveapi.api.hive_auth_async.calculate_u", return_value=0): - with pytest.raises(ValueError, match="U cannot be zero"): - auth.get_password_authentication_key( - "testuser", "testpass", server_b_value, salt - ) - - async def test_accepts_integer_server_b(self): - """server_b_value can be passed as an integer (handled by _to_int).""" - auth = await _make_auth() - from apyhiveapi.api.srp_crypto import get_random - - server_b_int = get_random(128) - salt = hex(get_random(16))[2:] - - with patch("apyhiveapi.api.hive_auth_async.calculate_u", return_value=12345): - result = auth.get_password_authentication_key( - "testuser", "testpass", server_b_int, salt - ) - - assert isinstance(result, (bytes, bytearray)) - - -# --------------------------------------------------------------------------- -# Tests: process_challenge() — lines 203-254 -# --------------------------------------------------------------------------- - - -class TestProcessChallenge: - """Cover lines 205-254: process_challenge() builds the SRP response.""" - - def _make_challenge_params(self, salt_as_int=False): - """Return a minimal valid challenge_parameters dict.""" - import base64 - - salt = "aabbccddee" - if salt_as_int: - salt = int("aabbccddee", 16) - return { - "USER_ID_FOR_SRP": "challenge-user@test.com", - "SALT": salt, - "SRP_B": "ff" * 32, # arbitrary hex - "SECRET_BLOCK": base64.b64encode(b"secret-block-bytes").decode(), - } - - async def test_returns_required_keys(self): - """Basic challenge response includes mandatory SRP keys.""" - auth = await _make_auth() - - fake_hkdf = b"\x00" * 32 - auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) - - params = self._make_challenge_params() - result = await auth.process_challenge(params) - - assert "TIMESTAMP" in result - assert "USERNAME" in result - assert "PASSWORD_CLAIM_SECRET_BLOCK" in result - assert "PASSWORD_CLAIM_SIGNATURE" in result - - async def test_sets_user_id_from_challenge(self): - """process_challenge stores USER_ID_FOR_SRP as self.user_id.""" - auth = await _make_auth() - - fake_hkdf = b"\x00" * 32 - auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) - - params = self._make_challenge_params() - await auth.process_challenge(params) - - assert auth.user_id == "challenge-user@test.com" - - async def test_with_client_secret_adds_secret_hash(self): - """When client_secret is set, SECRET_HASH is added to the response.""" - auth = await _make_auth(client_secret="my-secret") - - fake_hkdf = b"\x00" * 32 - auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) - - params = self._make_challenge_params() - result = await auth.process_challenge(params) - - assert "SECRET_HASH" in result - - async def test_without_client_secret_no_secret_hash(self): - """When client_secret is None, SECRET_HASH is absent from the response.""" - auth = await _make_auth(client_secret=None) - - fake_hkdf = b"\x00" * 32 - auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) - - params = self._make_challenge_params() - result = await auth.process_challenge(params) - - assert "SECRET_HASH" not in result - - async def test_with_device_key_adds_device_key(self): - """When device_key is set, DEVICE_KEY is added to the response.""" - auth = await _make_auth(device_key="dk-challenge") - - fake_hkdf = b"\x00" * 32 - auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) - - params = self._make_challenge_params() - result = await auth.process_challenge(params) - - assert result["DEVICE_KEY"] == "dk-challenge" - - async def test_without_device_key_no_device_key_in_response(self): - """When device_key is None, DEVICE_KEY is absent from the response.""" - auth = await _make_auth(device_key=None) - - fake_hkdf = b"\x00" * 32 - auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) - - params = self._make_challenge_params() - result = await auth.process_challenge(params) - - assert "DEVICE_KEY" not in result - - async def test_salt_as_integer_triggers_pad_hex(self): - """When SALT is an integer (not str), pad_hex is applied before use.""" - auth = await _make_auth() - - fake_hkdf = b"\x00" * 32 - auth.loop.run_in_executor = AsyncMock(return_value=fake_hkdf) - - params = self._make_challenge_params(salt_as_int=True) - # Should not raise; int-type SALT is handled by the isinstance check - result = await auth.process_challenge(params) - - assert "TIMESTAMP" in result - - -# --------------------------------------------------------------------------- -# Tests: login() — client is None triggers async_init (line 262-263) -# --------------------------------------------------------------------------- - - -class TestLoginClientNone: - """Cover line 263: when client is None, async_init() is awaited.""" - - async def test_login_calls_async_init_when_client_is_none(self): - """If client is None before login, async_init is called before SRP flow.""" - auth = await _make_auth() - auth.client = None # reset to trigger the branch - auth.use_file = False # ensure we go through the client-None path - - auth_result = {"AuthenticationResult": {"AccessToken": "post-init-token"}} - challenge_response = { - "ChallengeName": "PASSWORD_VERIFIER", - "ChallengeParameters": { - "USER_ID_FOR_SRP": "user@test.com", - "SALT": "aabbccdd", - "SRP_B": "ccddee", - "SECRET_BLOCK": "YWJj", - }, - } - - async def fake_async_init(): - auth.client = MagicMock() - auth._client_id = "test-client-id" - auth._pool_id = "eu-west-1_TestPool" - auth._region = "eu-west-1" - - with patch.object(auth, "async_init", side_effect=fake_async_init) as mock_init: - with patch.object( - auth, "process_challenge", new_callable=AsyncMock - ) as mock_ch: - mock_ch.return_value = { - "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", - "USERNAME": "user", - } - # After async_init, run_in_executor is called for initiate_auth then - # respond_to_auth_challenge - auth.loop.run_in_executor = AsyncMock( - side_effect=[challenge_response, auth_result] - ) - result = await auth.login() - - mock_init.assert_called_once() - assert "AuthenticationResult" in result - - -# --------------------------------------------------------------------------- -# Tests: login() — unsupported challenge name (lines 335-337) -# --------------------------------------------------------------------------- - - -class TestLoginUnsupportedChallenge: - """Cover lines 335-337: non-PASSWORD_VERIFIER challenge raises NotImplementedError.""" - - async def test_new_password_required_raises_not_implemented(self): - """NEW_PASSWORD_REQUIRED challenge is not supported and raises NotImplementedError.""" - auth = await _make_auth() - auth.loop.run_in_executor = AsyncMock( - return_value={ - "ChallengeName": "NEW_PASSWORD_REQUIRED", - "ChallengeParameters": {}, - } - ) - with pytest.raises(NotImplementedError, match="NEW_PASSWORD_REQUIRED"): - await auth.login() - - async def test_custom_challenge_raises_not_implemented(self): - """Any unknown challenge name raises NotImplementedError.""" - auth = await _make_auth() - auth.loop.run_in_executor = AsyncMock( - return_value={ - "ChallengeName": "UNKNOWN_CHALLENGE_TYPE", - "ChallengeParameters": {}, - } - ) - with pytest.raises(NotImplementedError, match="UNKNOWN_CHALLENGE_TYPE"): - await auth.login() - - -# --------------------------------------------------------------------------- -# Tests: login() — respond_to_auth_challenge ResourceNotFoundException (line 307-311) -# --------------------------------------------------------------------------- - - -class TestLoginResourceNotFound: - """Cover lines 307-311: ResourceNotFoundException in respond_to_auth_challenge.""" - - async def test_resource_not_found_raises_invalid_device_authentication(self): - """ResourceNotFoundException during challenge → HiveInvalidDeviceAuthentication.""" - auth = await _make_auth() - challenge_response = { - "ChallengeName": "PASSWORD_VERIFIER", - "ChallengeParameters": { - "USER_ID_FOR_SRP": "user@test.com", - "SALT": "aabbccdd", - "SRP_B": "ccddee", - "SECRET_BLOCK": "YWJj", - }, - } - resource_err = _named_client_error("ResourceNotFoundException") - with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: - mock_ch.return_value = { - "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", - "USERNAME": "user", - } - auth.loop.run_in_executor = AsyncMock( - side_effect=[challenge_response, resource_err] - ) - with pytest.raises(HiveInvalidDeviceAuthentication): - await auth.login() - - -# --------------------------------------------------------------------------- -# Tests: login() — EndpointConnectionError in respond_to_auth_challenge (lines 312-317) -# --------------------------------------------------------------------------- - - -class TestLoginEndpointErrorOnChallenge: - """Cover lines 312-317: EndpointConnectionError during respond_to_auth_challenge.""" - - async def test_endpoint_error_on_challenge_raises_api_error(self): - """EndpointConnectionError during SRP challenge response → HiveApiError.""" - auth = await _make_auth() - challenge_response = { - "ChallengeName": "PASSWORD_VERIFIER", - "ChallengeParameters": { - "USER_ID_FOR_SRP": "user@test.com", - "SALT": "aabbccdd", - "SRP_B": "ccddee", - "SECRET_BLOCK": "YWJj", - }, - } - with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: - mock_ch.return_value = { - "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", - "USERNAME": "user", - } - auth.loop.run_in_executor = AsyncMock( - side_effect=[challenge_response, _endpoint_error()] - ) - with pytest.raises(HiveApiError): - await auth.login() - - -# --------------------------------------------------------------------------- -# Tests: login() — result without AuthenticationResult (lines 321-333) -# --------------------------------------------------------------------------- - - -class TestLoginResultHandling: - """Cover lines 321-333: AuthenticationResult presence/absence in login result.""" - - async def test_result_without_authentication_result_does_not_store_token(self): - """If result lacks 'AuthenticationResult', access_token is not set.""" - auth = await _make_auth() - # First call → PASSWORD_VERIFIER challenge - # Second call → result without AuthenticationResult (e.g., SMS_MFA) - challenge_response = { - "ChallengeName": "PASSWORD_VERIFIER", - "ChallengeParameters": { - "USER_ID_FOR_SRP": "user@test.com", - "SALT": "aabbccdd", - "SRP_B": "ccddee", - "SECRET_BLOCK": "YWJj", - }, - } - sms_challenge_result = { - "ChallengeName": "SMS_MFA", - "Session": "session-tok", - "ChallengeParameters": {}, - } - with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: - mock_ch.return_value = { - "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", - "USERNAME": "user", - } - auth.loop.run_in_executor = AsyncMock( - side_effect=[challenge_response, sms_challenge_result] - ) - result = await auth.login() - - # access_token was never set - assert auth.access_token is None - assert result is sms_challenge_result - - async def test_result_with_authentication_result_but_no_new_device_metadata(self): - """AuthenticationResult without NewDeviceMetadata sets access_token only.""" - auth = await _make_auth() - challenge_response = { - "ChallengeName": "PASSWORD_VERIFIER", - "ChallengeParameters": { - "USER_ID_FOR_SRP": "user@test.com", - "SALT": "aabbccdd", - "SRP_B": "ccddee", - "SECRET_BLOCK": "YWJj", - }, - } - auth_result = {"AuthenticationResult": {"AccessToken": "my-access-token"}} - with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: - mock_ch.return_value = { - "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", - "USERNAME": "user", - } - auth.loop.run_in_executor = AsyncMock( - side_effect=[challenge_response, auth_result] - ) - await auth.login() - - assert auth.access_token == "my-access-token" - assert auth.device_group_key is None - assert auth.device_key is None - - -# --------------------------------------------------------------------------- -# Tests: device_login() — client is None (line 347-348) -# --------------------------------------------------------------------------- - - -class TestDeviceLoginClientNone: - """Cover device_login's async_init call when client is None.""" - - async def test_device_login_calls_async_init_when_client_is_none(self): - """If client is None, async_init is called before proceeding.""" - auth = await _make_auth(device_key="dk-1") - auth.client = None - - async def fake_async_init(): - auth.client = MagicMock() - auth._client_id = "test-client-id" - - with patch.object(auth, "async_init", side_effect=fake_async_init) as mock_init: - auth.loop.run_in_executor = AsyncMock( - side_effect=_named_client_error("ResourceNotFoundException") - ) - with pytest.raises(HiveInvalidDeviceAuthentication): - await auth.device_login() - - mock_init.assert_called_once() - - -# --------------------------------------------------------------------------- -# Tests: sms_2fa() — NewDeviceMetadata absent (line 441) -# --------------------------------------------------------------------------- - - -class TestSms2faNoNewDeviceMetadata: - """Cover line 441: sms_2fa when NewDeviceMetadata is absent in result.""" - - async def test_no_new_device_metadata_does_not_set_device_keys(self): - """When NewDeviceMetadata is absent, device_group_key and device_key stay None.""" - auth = await _make_auth() - original_group_key = auth.device_group_key # None - original_device_key = auth.device_key # None - - sms_result = { - "AuthenticationResult": { - "AccessToken": "sms-access-token", - # No "NewDeviceMetadata" key - } - } - auth.loop.run_in_executor = AsyncMock(return_value=sms_result) - - result = await auth.sms_2fa("654321", {"Session": "sess-abc"}) - - assert auth.access_token == "sms-access-token" - assert auth.device_group_key == original_group_key # unchanged - assert auth.device_key == original_device_key # unchanged - assert result is sms_result - - -# --------------------------------------------------------------------------- -# Tests: sms_2fa() — CodeMismatchException path (lines 424-429) -# --------------------------------------------------------------------------- - - -class TestSms2faCodeMismatch: - """Cover lines 424-429: CodeMismatchException raises HiveInvalid2FACode.""" - - async def test_code_mismatch_raises_invalid_2fa_code(self): - """CodeMismatchException in sms_2fa raises HiveInvalid2FACode.""" - auth = await _make_auth() - auth.loop.run_in_executor = AsyncMock( - side_effect=_named_client_error("CodeMismatchException") - ) - with pytest.raises(HiveInvalid2FACode): - await auth.sms_2fa("000000", {"Session": "sess-1"}) - - async def test_not_authorized_raises_invalid_2fa_code(self): - """NotAuthorizedException in sms_2fa raises HiveInvalid2FACode.""" - auth = await _make_auth() - auth.loop.run_in_executor = AsyncMock( - side_effect=_named_client_error("NotAuthorizedException") - ) - with pytest.raises(HiveInvalid2FACode): - await auth.sms_2fa("111111", {"Session": "sess-2"}) - - -# --------------------------------------------------------------------------- -# Tests: refresh_token() — client is None (line 440-441) -# --------------------------------------------------------------------------- - - -class TestRefreshTokenClientNone: - """Cover line 440-441: refresh_token calls async_init when client is None.""" - - async def test_refresh_token_calls_async_init_when_client_is_none(self): - """If client is None, async_init is awaited before refreshing.""" - auth = await _make_auth() - auth.client = None - - result_payload = {"AuthenticationResult": {"AccessToken": "refreshed-tok"}} - - async def fake_async_init(): - auth.client = MagicMock() - - with patch.object(auth, "async_init", side_effect=fake_async_init) as mock_init: - auth.loop.run_in_executor = AsyncMock(return_value=result_payload) - result = await auth.refresh_token("some-refresh-token") - - mock_init.assert_called_once() - assert result is result_payload - - -# --------------------------------------------------------------------------- -# Tests: refresh_token() — result path when no AuthenticationResult (lines 479-485) -# --------------------------------------------------------------------------- - - -class TestRefreshTokenResultPath: - """Cover lines 479-485: refresh_token when result is returned normally.""" - - async def test_returns_result_directly(self): - """refresh_token returns the result from Cognito directly.""" - auth = await _make_auth() - result_payload = {"AuthenticationResult": {"AccessToken": "tok-xyz"}} - auth.loop.run_in_executor = AsyncMock(return_value=result_payload) - - result = await auth.refresh_token("refresh-tok-abc") - - assert result is result_payload - - async def test_with_device_key_includes_device_key_param(self): - """When device_key is set, DEVICE_KEY is included in auth_params.""" - auth = await _make_auth(device_key="dk-refresh-001") - result_payload = {"AuthenticationResult": {"AccessToken": "tok-dk"}} - auth.loop.run_in_executor = AsyncMock(return_value=result_payload) - - result = await auth.refresh_token("refresh-tok-dk") - - assert result is result_payload - - async def test_without_device_key_sends_only_refresh_token(self): - """When device_key is None, auth_params has only REFRESH_TOKEN.""" - auth = await _make_auth(device_key=None) - result_payload = {"AuthenticationResult": {"AccessToken": "tok-no-dk"}} - auth.loop.run_in_executor = AsyncMock(return_value=result_payload) - - result = await auth.refresh_token("refresh-tok-no-dk") - - assert result is result_payload - - -# --------------------------------------------------------------------------- -# Tests: login() — swallowed ClientError in initiate_auth (line 280->288) -# --------------------------------------------------------------------------- - - -class TestLoginInitiateAuthSwallowedClientError: - """Arc 280->288: ClientError caught but class name is not UserNotFoundException.""" - - async def test_other_client_error_in_initiate_auth_falls_through(self): - """Non-UserNotFoundException ClientError raises HiveApiError.""" - auth = await _make_auth() - - wrong_cls = type("SomeOtherError", (botocore.exceptions.ClientError,), {}) - wrong_err = wrong_cls( - {"Error": {"Code": "SomeOtherError", "Message": "msg"}}, "op" - ) - auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) - - with pytest.raises(HiveApiError): - await auth.login() - - -# --------------------------------------------------------------------------- -# Tests: login() — swallowed EndpointConnectionError in initiate_auth (line 284->288) -# --------------------------------------------------------------------------- - - -class TestLoginInitiateAuthSwallowedEndpointError: - """EndpointConnectionError in initiate_auth always raises HiveApiError.""" - - async def test_wrong_name_endpoint_error_in_initiate_auth_raises_api_error(self): - """Any EndpointConnectionError subclass in initiate_auth raises HiveApiError.""" - auth = await _make_auth() - - wrong_cls = type( - "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} - ) - wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") - auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) - - with pytest.raises(HiveApiError): - await auth.login() - - -# --------------------------------------------------------------------------- -# Tests: login() — swallowed ClientError in respond_to_auth_challenge (307->319) -# --------------------------------------------------------------------------- - - -class TestLoginChallengeSwallowedClientError: - """Arc 307->319: ClientError caught in challenge response with name not matching.""" - - async def test_other_client_error_in_challenge_falls_through(self): - """ClientError that is neither NotAuthorized nor ResourceNotFound raises HiveApiError.""" - auth = await _make_auth() - - challenge_response = { - "ChallengeName": "PASSWORD_VERIFIER", - "ChallengeParameters": { - "USER_ID_FOR_SRP": "user@test.com", - "SALT": "aabbccdd", - "SRP_B": "ccddee", - "SECRET_BLOCK": "YWJj", - }, - } - - wrong_cls = type("ThirdPartyError", (botocore.exceptions.ClientError,), {}) - wrong_err = wrong_cls( - {"Error": {"Code": "ThirdPartyError", "Message": "msg"}}, "op" - ) - - with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: - mock_ch.return_value = {"TIMESTAMP": "...", "USERNAME": "user"} - auth.loop.run_in_executor = AsyncMock( - side_effect=[challenge_response, wrong_err] - ) - with pytest.raises(HiveApiError): - await auth.login() - - -# --------------------------------------------------------------------------- -# Tests: login() — swallowed EndpointConnectionError in challenge (313->319) -# --------------------------------------------------------------------------- - - -class TestLoginChallengeSwallowedEndpointError: - """EndpointConnectionError in respond_to_auth_challenge always raises HiveApiError.""" - - async def test_wrong_name_endpoint_error_in_challenge_raises_api_error(self): - """Any EndpointConnectionError subclass in SRP challenge raises HiveApiError.""" - auth = await _make_auth() - - challenge_response = { - "ChallengeName": "PASSWORD_VERIFIER", - "ChallengeParameters": { - "USER_ID_FOR_SRP": "user@test.com", - "SALT": "aabbccdd", - "SRP_B": "ccddee", - "SECRET_BLOCK": "YWJj", - }, - } - - wrong_cls = type( - "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} - ) - wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") - - with patch.object(auth, "process_challenge", new_callable=AsyncMock) as mock_ch: - mock_ch.return_value = {"TIMESTAMP": "...", "USERNAME": "user"} - auth.loop.run_in_executor = AsyncMock( - side_effect=[challenge_response, wrong_err] - ) - with pytest.raises(HiveApiError): - await auth.login() - - -# --------------------------------------------------------------------------- -# Tests: device_login() — success path through process_device_challenge (lines 364-367, 391) -# --------------------------------------------------------------------------- - - -class TestDeviceLoginSuccessPath: - """Lines 364-367, 391: device_login processes device challenge and returns result.""" - - async def test_successful_device_login_returns_auth_result(self): - """Full device_login success: process_device_challenge called, result returned.""" - auth = await _make_auth(device_key="dk-abc", device_group_key="grp-abc") - auth.device_password = "dev-pass" - - initial_result = { - "ChallengeParameters": { - "USERNAME": "user@test.com", - "SALT": "aabbccdd", - "SRP_B": "ccddee", - "SECRET_BLOCK": "YWJj", - } - } - final_result = {"AuthenticationResult": {"AccessToken": "device-access-token"}} - - with patch.object( - auth, "process_device_challenge", new_callable=AsyncMock - ) as mock_pdc: - mock_pdc.return_value = { - "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", - "USERNAME": "user@test.com", - "PASSWORD_CLAIM_SECRET_BLOCK": "YWJj", - "PASSWORD_CLAIM_SIGNATURE": "sig", - "DEVICE_KEY": "dk-abc", - } - auth.loop.run_in_executor = AsyncMock( - side_effect=[initial_result, final_result] - ) - result = await auth.device_login() - - mock_pdc.assert_called_once_with(initial_result["ChallengeParameters"]) - assert result is final_result - - async def test_device_login_calls_second_respond_to_auth_challenge(self): - """Lines 367-375: second respond_to_auth_challenge is called with device challenge.""" - auth = await _make_auth(device_key="dk-xyz", device_group_key="grp-xyz") - auth.device_password = "dev-pass-xyz" - - initial_result = { - "ChallengeParameters": { - "USERNAME": "user@test.com", - "SALT": "11223344", - "SRP_B": "55667788", - "SECRET_BLOCK": "dGVzdA==", - } - } - final_result = {"AuthenticationResult": {"AccessToken": "tok-xyz"}} - - challenge_resp = { - "TIMESTAMP": "Mon Jan 01 00:00:00 UTC 2024", - "USERNAME": "user@test.com", - "PASSWORD_CLAIM_SECRET_BLOCK": "dGVzdA==", - "PASSWORD_CLAIM_SIGNATURE": "sig", - "DEVICE_KEY": "dk-xyz", - } - - with patch.object( - auth, "process_device_challenge", new_callable=AsyncMock - ) as mock_pdc: - mock_pdc.return_value = challenge_resp - auth.loop.run_in_executor = AsyncMock( - side_effect=[initial_result, final_result] - ) - result = await auth.device_login() - - assert auth.loop.run_in_executor.call_count == 2 - assert result["AuthenticationResult"]["AccessToken"] == "tok-xyz" - - -# --------------------------------------------------------------------------- -# Tests: device_login() — wrong-name EndpointConnectionError (line 389) -# --------------------------------------------------------------------------- - - -class TestDeviceLoginEndpointWrongName: - """Any EndpointConnectionError in device_login always raises HiveApiError.""" - - async def test_wrong_name_endpoint_error_raises_api_error(self): - """Any EndpointConnectionError subclass in device_login raises HiveApiError.""" - auth = await _make_auth(device_key="dk-err", device_group_key="grp-err") - auth.device_password = "dev-pass-err" - - wrong_cls = type( - "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} - ) - wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") - auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) - - with pytest.raises(HiveApiError): - await auth.device_login() - - -# --------------------------------------------------------------------------- -# Tests: sms_2fa() — swallowed ClientError (arc 424->435) -# --------------------------------------------------------------------------- - - -class TestSms2faSwallowedClientError: - """Arc 424->435: ClientError caught in sms_2fa with unrecognised class name.""" - - async def test_other_client_error_is_swallowed_returns_none(self): - """Non-matching ClientError is swallowed; result stays None (returned).""" - auth = await _make_auth() - - wrong_cls = type("OtherError", (botocore.exceptions.ClientError,), {}) - wrong_err = wrong_cls({"Error": {"Code": "OtherError", "Message": "msg"}}, "op") - auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) - - result = await auth.sms_2fa("123456", {"Session": "sess-xyz"}) - assert ( - result is None - ) # sms_2fa initialises result=None; swallowed → returns None - - -# --------------------------------------------------------------------------- -# Tests: sms_2fa() — swallowed EndpointConnectionError (arc 431->435) -# --------------------------------------------------------------------------- - - -class TestSms2faSwallowedEndpointError: - """Any EndpointConnectionError in sms_2fa raises HiveApiError.""" - - async def test_wrong_name_endpoint_error_raises_api_error(self): - """Any EndpointConnectionError subclass in sms_2fa raises HiveApiError.""" - auth = await _make_auth() - - wrong_cls = type( - "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} - ) - wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") - auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) - - with pytest.raises(HiveApiError): - await auth.sms_2fa("654321", {"Session": "sess-abc"}) - - -# --------------------------------------------------------------------------- -# Tests: refresh_token() — swallowed EndpointConnectionError (arc 479->485) -# --------------------------------------------------------------------------- - - -class TestRefreshTokenSwallowedEndpointError: - """Any EndpointConnectionError in refresh_token raises HiveApiError.""" - - async def test_wrong_name_endpoint_error_raises_api_error(self): - """Any EndpointConnectionError subclass in refresh_token raises HiveApiError.""" - auth = await _make_auth() - - wrong_cls = type( - "WrongEndpoint", (botocore.exceptions.EndpointConnectionError,), {} - ) - wrong_err = wrong_cls(endpoint_url="https://cognito.eu-west-1.amazonaws.com") - auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) - - with pytest.raises(HiveApiError): - await auth.refresh_token("some-refresh-token") - - -# --------------------------------------------------------------------------- -# Tests: async_init() — missing REGION or UPID keys -# --------------------------------------------------------------------------- - - -class TestAsyncInitMissingKeys: - """async_init must raise HiveUnknownConfiguration when login info keys are absent.""" - - async def test_async_init_missing_region_raises_configuration_error(self): - """If REGION is absent from login info, raise HiveUnknownConfiguration.""" - from apyhiveapi.api.hive_auth_async import HiveAuthAsync - from apyhiveapi.helper.hive_exceptions import HiveUnknownConfiguration - - auth = HiveAuthAsync(username="user@test.com", password="pass") - bad_login_info = {"UPID": "eu-west-1_TestPool", "CLIID": "test-client-id"} - mock_loop = MagicMock() - mock_loop.run_in_executor = AsyncMock(side_effect=[bad_login_info]) - with patch("asyncio.get_running_loop", return_value=mock_loop): - with pytest.raises(HiveUnknownConfiguration): - await auth.async_init() - - async def test_async_init_missing_upid_raises_configuration_error(self): - """If UPID is absent from login info, raise HiveUnknownConfiguration.""" - from apyhiveapi.api.hive_auth_async import HiveAuthAsync - from apyhiveapi.helper.hive_exceptions import HiveUnknownConfiguration - - auth = HiveAuthAsync(username="user@test.com", password="pass") - bad_login_info = {"CLIID": "test-client-id", "REGION": "eu-west-1_TestPool"} - mock_loop = MagicMock() - mock_loop.run_in_executor = AsyncMock(side_effect=[bad_login_info]) - with patch("asyncio.get_running_loop", return_value=mock_loop): - with pytest.raises(HiveUnknownConfiguration): - await auth.async_init() - - -# --------------------------------------------------------------------------- -# Tests: get_password_authentication_key() — None _pool_id -# --------------------------------------------------------------------------- - - -class TestGetPasswordAuthKeyNonePoolId: - """get_password_authentication_key must not crash with AttributeError when _pool_id is None.""" - - async def test_none_pool_id_raises_configuration_error(self): - """If _pool_id is None, raise HiveUnknownConfiguration (not AttributeError).""" - from apyhiveapi.helper.hive_exceptions import HiveUnknownConfiguration - - auth = await _make_auth() - auth._pool_id = None - with pytest.raises(HiveUnknownConfiguration): - auth.get_password_authentication_key("user", "pass", "DEADBEEF", "ABCDEF") - - -# --------------------------------------------------------------------------- -# Tests: login() — unhandled ClientError codes must raise HiveApiError -# --------------------------------------------------------------------------- - - -class TestLoginUnhandledClientError: - """Unhandled ClientError codes in login() must raise HiveApiError, not crash.""" - - async def test_initiate_auth_unhandled_error_raises_hive_api_error(self): - """Non-UserNotFoundException ClientError from initiate_auth raises HiveApiError.""" - auth = await _make_auth() - err = botocore.exceptions.ClientError( - {"Error": {"Code": "TooManyRequestsException", "Message": "too many"}}, - "InitiateAuth", - ) - auth.loop.run_in_executor = AsyncMock(side_effect=err) - - with pytest.raises(HiveApiError): - await auth.login() - - async def test_respond_to_challenge_unhandled_error_raises_hive_api_error(self): - """Non-NotAuthorized/ResourceNotFound ClientError raises HiveApiError.""" - auth = await _make_auth() - challenge_response = { - "ChallengeName": "PASSWORD_VERIFIER", - "ChallengeParameters": { - "USER_ID_FOR_SRP": "user@test.com", - "SALT": "aabbccdd", - "SRP_B": "ccddee", - "SECRET_BLOCK": "YWJj", - }, - } - respond_err = botocore.exceptions.ClientError( - {"Error": {"Code": "InternalErrorException", "Message": "internal"}}, - "RespondToAuthChallenge", - ) - auth.loop.run_in_executor = AsyncMock( - side_effect=[challenge_response, respond_err] - ) - auth.process_challenge = AsyncMock( - return_value={"TIMESTAMP": "t", "USERNAME": "u"} - ) - - with pytest.raises(HiveApiError): - await auth.login() - - -# --------------------------------------------------------------------------- -# Tests: async_init() — None return from get_login_info raises HiveUnknownConfiguration -# --------------------------------------------------------------------------- - - -class TestAsyncInitNoneGuard: - """async_init must guard against get_login_info returning None.""" - - async def test_async_init_raises_when_login_info_is_none(self): - """async_init raises HiveUnknownConfiguration when get_login_info returns None.""" - from apyhiveapi.api.hive_auth_async import HiveAuthAsync - from apyhiveapi.helper.hive_exceptions import HiveUnknownConfiguration - - auth = HiveAuthAsync(username="user@test.com", password="pass") - auth.client = None - - mock_loop = MagicMock() - mock_loop.run_in_executor = AsyncMock(return_value=None) - - with patch("asyncio.get_running_loop", return_value=mock_loop): - with pytest.raises(HiveUnknownConfiguration): - await auth.async_init() diff --git a/tests/unit/test_hive_helper_extended.py b/tests/unit/test_hive_helper_extended.py deleted file mode 100644 index c81243d..0000000 --- a/tests/unit/test_hive_helper_extended.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Tests for HiveHelper covering previously uncovered lines/branches.""" - -# pylint: disable=protected-access - -from unittest.mock import MagicMock - -from apyhiveapi.helper.hive_helper import HiveHelper -from apyhiveapi.helper.map import Map - - -def _make_helper(entity_cache=None, products=None): - """Build a HiveHelper with a minimally mocked session.""" - session = MagicMock() - session.entity_cache = entity_cache if entity_cache is not None else {} - session.data = Map( - { - "products": products or {}, - "devices": {}, - "actions": {}, - "user": {}, - "minMax": {}, - } - ) - return HiveHelper(session) - - -# --------------------------------------------------------------------------- -# get_device_from_id — branch 133->122 (no match, loop continues then exits) -# --------------------------------------------------------------------------- - - -class TestGetDeviceFromIdBranch: - """Covers the branch where no cache entry matches the requested ID.""" - - def test_returns_false_when_no_match_in_cache(self): - """When entity_cache has entries but none match n_id, returns False. - - This exercises the branch where the 'if n_id in (hive_id, device_id)' - condition is False for every item (133->122 loop-continue then exit). - """ - from apyhiveapi.helper.hivedataclasses import Device - - other_device = Device( - hive_id="other-hive-id", - hive_name="Other", - hive_type="heating", - ha_type="climate", - device_id="other-device-id", - device_name="Other", - device_data={}, - ) - helper = _make_helper(entity_cache={"other-key": other_device}) - result = helper.get_device_from_id("nonexistent-id") - assert result is False - - def test_returns_false_when_cache_is_empty(self): - """When entity_cache is empty, returns False without entering the loop.""" - helper = _make_helper(entity_cache={}) - assert helper.get_device_from_id("any-id") is False - - -# --------------------------------------------------------------------------- -# sanitize_payload — list masking (line 329) and non-str/dict/list fallthrough -# --------------------------------------------------------------------------- - - -class TestSanitizePayload: - """Covers _mask branches for list values and non-string scalar fallthrough.""" - - def test_list_value_under_sensitive_key_is_masked(self): - """A list value under a sensitive key has each element masked.""" - helper = _make_helper() - payload = {"token": ["short", "averylongtoken123"]} - result = helper.sanitize_payload(payload) - # "short" (<=8 chars) → "***", "averylongtoken123" (>8 chars) → "aver...n123" - assert result["token"] == ["***", "aver...n123"] - - def test_non_string_non_dict_non_list_under_sensitive_key_passes_through(self): - """An int/bool/None value under a sensitive key is returned as-is.""" - helper = _make_helper() - payload = {"token": 42} - result = helper.sanitize_payload(payload) - assert result["token"] == 42 - - def test_none_under_sensitive_key_passes_through(self): - """None under a sensitive key is returned unchanged.""" - helper = _make_helper() - payload = {"token": None} - result = helper.sanitize_payload(payload) - assert result["token"] is None - - def test_bool_under_sensitive_key_passes_through(self): - """A bool under a sensitive key is returned unchanged (not a str/dict/list).""" - helper = _make_helper() - payload = {"token": True} - result = helper.sanitize_payload(payload) - assert result["token"] is True - - def test_short_string_masked_as_stars(self): - """A string of 8 characters or fewer is masked as '***'.""" - helper = _make_helper() - payload = {"password": "abc12345"} # exactly 8 chars - result = helper.sanitize_payload(payload) - assert result["password"] == "***" - - def test_long_string_partially_masked(self): - """A string longer than 8 characters is partially masked.""" - helper = _make_helper() - payload = {"password": "supersecretpassword"} - result = helper.sanitize_payload(payload) - assert result["password"] == "supe...word" - - -# --------------------------------------------------------------------------- -# epoch_time — to_epoch must honour the pattern argument -# --------------------------------------------------------------------------- - - -class TestEpochTimePattern: - """epoch_time to_epoch must honour the pattern argument.""" - - def test_to_epoch_uses_caller_pattern(self): - """Passing a custom pattern must parse the date string with that pattern.""" - from apyhiveapi.helper.hive_helper import epoch_time - - # ISO date — only parses if the custom pattern is respected - result = epoch_time("2024-06-15", "%Y-%m-%d", "to_epoch") - assert isinstance(result, int), "Expected int epoch timestamp" - assert result > 0 - - def test_to_epoch_standard_hive_format_still_works(self): - """The standard Hive date+time format must still parse correctly.""" - from apyhiveapi.helper.hive_helper import epoch_time - - result = epoch_time("15.06.2024 12:00:00", "%d.%m.%Y %H:%M:%S", "to_epoch") - assert isinstance(result, int) - assert result > 0 - - -def _sample_schedule(): - """Minimal 7-day schedule with 3 slots on every day.""" - days = [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", - ] - schedule = {} - for d in days: - schedule[d] = [ - {"start": 0, "value": {"status": "ON"}}, - {"start": 480, "value": {"status": "OFF"}}, - {"start": 1200, "value": {"status": "ON"}}, - ] - return schedule - - -class TestGetScheduleNnlMutation: - """get_schedule_nnl must not mutate the input schedule dicts.""" - - def test_second_call_returns_same_result_as_first_call(self): - """Calling get_schedule_nnl twice on the same schedule dict gives consistent results.""" - h = _make_helper() - schedule = _sample_schedule() - - result1 = h.get_schedule_nnl(schedule) - result2 = h.get_schedule_nnl(schedule) - - assert result1.get("now", {}).get("value") == result2.get("now", {}).get( - "value" - ), "Second call returned different 'now' value — schedule was mutated in-place" - - def test_input_schedule_slots_not_modified(self): - """Slot dicts in the input schedule must not gain 'Start_DateTime' after the call.""" - h = _make_helper() - schedule = _sample_schedule() - monday_slot_before = dict(schedule["monday"][0]) - - h.get_schedule_nnl(schedule) - - assert schedule["monday"][0] == monday_slot_before, ( - "get_schedule_nnl mutated the original slot dict" - ) diff --git a/tests/unit/test_hotwater_extended.py b/tests/unit/test_hotwater.py similarity index 100% rename from tests/unit/test_hotwater_extended.py rename to tests/unit/test_hotwater.py diff --git a/tests/unit/test_light_extended.py b/tests/unit/test_light.py similarity index 100% rename from tests/unit/test_light_extended.py rename to tests/unit/test_light.py diff --git a/tests/unit/test_sensor_extended.py b/tests/unit/test_sensor.py similarity index 100% rename from tests/unit/test_sensor_extended.py rename to tests/unit/test_sensor.py diff --git a/tests/unit/test_session_auth_extended.py b/tests/unit/test_session_auth.py similarity index 100% rename from tests/unit/test_session_auth_extended.py rename to tests/unit/test_session_auth.py diff --git a/tests/unit/test_session_discovery_extended.py b/tests/unit/test_session_discovery.py similarity index 99% rename from tests/unit/test_session_discovery_extended.py rename to tests/unit/test_session_discovery.py index 48d1c02..0815bbd 100644 --- a/tests/unit/test_session_discovery_extended.py +++ b/tests/unit/test_session_discovery.py @@ -140,7 +140,7 @@ async def test_with_device_data_3_items_sets_auth_keys(self): ) assert s.auth.device_group_key == "grp-key" assert s.auth.device_key == "dev-key" - assert s.auth.device_password == "dev-pass" + assert s.auth.device_password == "dev-pass" # pragma: allowlist secret async def test_with_device_data_4_items_sets_token_created(self): """4-item device_data with a token_created timestamp sets tokens.token_created.""" From 776a2a3d2c2b18e1ad2e1d73ba212cdc89dd7e8d Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:54:13 +0100 Subject: [PATCH 28/29] refactor: consolidate API error handling and remove deprecated code - Extracted _call_endpoint helper in both sync/async API classes to eliminate repetitive try/except blocks across get_all, get_devices, get_products, get_actions, motion_sensor, get_weather, set_state, and set_action - Removed get_login_info from HiveApiAsync (unused; sync version retained) - Replaced manual JSON string building in set_state with json.dumps - Replaced fragile string manipulation for SSO parsing with regex in --- .secrets.baseline | 4 +- src/api/hive_api.py | 183 ++++++++----------------- src/api/hive_async_api.py | 137 +++++------------- src/api/hive_auth_async.py | 53 ++++--- src/devices/heating.py | 79 ++++++----- src/devices/light.py | 4 +- src/session/__init__.py | 5 +- src/session/auth.py | 18 ++- src/session/discovery.py | 11 +- src/session/polling.py | 3 +- tests/module/test_hub.py | 6 +- tests/module/test_session.py | 3 +- tests/unit/test_heating.py | 25 ++++ tests/unit/test_hive_api.py | 97 ++++++++++++- tests/unit/test_hive_async_api.py | 91 +++--------- tests/unit/test_hive_auth_async.py | 61 +++++++-- tests/unit/test_light.py | 17 +++ tests/unit/test_session_auth.py | 78 +++++++++++ tests/unit/test_session_close.py | 9 ++ tests/unit/test_session_discovery.py | 12 ++ tests/unit/test_session_get_devices.py | 33 +++++ 21 files changed, 545 insertions(+), 384 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index b80d7c3..9805f36 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -493,7 +493,7 @@ "filename": "tests/unit/test_hive_auth_async.py", "hashed_secret": "d8bce9746547bb7743e5933fbf0fc4f2d2cbcad3", "is_verified": false, - "line_number": 229 + "line_number": 272 } ], "tests/unit/test_hive_auth_async_extended.py": [ @@ -580,5 +580,5 @@ } ] }, - "generated_at": "2026-05-27T18:17:19Z" + "generated_at": "2026-06-16T18:53:56Z" } diff --git a/src/api/hive_api.py b/src/api/hive_api.py index 5cd7987..c4ae70c 100644 --- a/src/api/hive_api.py +++ b/src/api/hive_api.py @@ -2,15 +2,22 @@ import json import logging +import re import requests -import urllib3 from pyquery import PyQuery -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - _LOGGER = logging.getLogger(__name__) +_NO_RESPONSE = "No response to Hive API request" +_ERROR_RESPONSE = "Error making API call" + +# requests exceptions all subclass OSError; response.json() raises a +# json.JSONDecodeError subclass. +_REQUEST_ERRORS = (OSError, RuntimeError, json.JSONDecodeError) + +_SSO_ASSIGNMENT = re.compile(r'window\.(\w+)\s*=\s*"([^"]*)"') + class HiveApi: """Hive API Code.""" @@ -33,8 +40,8 @@ def __init__(self, hive_session=None, token=None): } self.timeout = 5 self.json_return = { - "original": "No response to Hive API request", - "parsed": "No response to Hive API request", + "original": _NO_RESPONSE, + "parsed": _NO_RESPONSE, } self.session = hive_session self.token = token @@ -74,6 +81,25 @@ def request(self, http_method, url, jsc=None): _LOGGER.error("Request failed: %s", e) raise + def _call_endpoint(self, http_method, url, jsc=None): + """Call an endpoint and return a fresh result dict for this call.""" + json_return = { + "original": _NO_RESPONSE, + "parsed": _NO_RESPONSE, + } + try: + response = self.request(http_method, url, jsc) + if response is not None: + json_return["original"] = response.status_code + json_return["parsed"] = response.json() + else: + _LOGGER.error("No response from Hive API call to %s", url) + except _REQUEST_ERRORS as e: + _LOGGER.error("Hive API call to %s failed: %s", url, e) + json_return = self.error() + + return json_return + def get_login_info(self): """Get login properties to make the login request.""" _LOGGER.debug( @@ -81,33 +107,21 @@ def get_login_info(self): ) url = self.urls["properties"] try: - data = requests.get(url=url, verify=False, timeout=self.timeout) + data = requests.get(url=url, timeout=self.timeout) _LOGGER.debug( "get_login_info - Login info response status: %s", data.status_code ) - html = PyQuery(data.content) - json_data = json.loads( - '{"' - + (html("script:first").text()) - .replace(",", ', "') - .replace("=", '":') - .replace("window.", "") - + "}" - ) - - login_data = {} - login_data.update({"UPID": json_data["HiveSSOPoolId"]}) - login_data.update({"CLIID": json_data["HiveSSOPublicCognitoClientId"]}) - login_data.update({"REGION": json_data["HiveSSOPoolId"]}) + script_text = PyQuery(data.content)("script:first").text() + sso_values = dict(_SSO_ASSIGNMENT.findall(script_text)) + + login_data = { + "UPID": sso_values["HiveSSOPoolId"], + "CLIID": sso_values["HiveSSOPublicCognitoClientId"], + "REGION": sso_values["HiveSSOPoolId"], + } _LOGGER.debug("get_login_info - Login info extracted successfully") return login_data - except ( - OSError, - RuntimeError, - ZeroDivisionError, - json.JSONDecodeError, - KeyError, - ) as e: + except (OSError, RuntimeError, KeyError) as e: _LOGGER.error("Failed to get login info: %s", str(e)) self.error() return None @@ -115,59 +129,23 @@ def get_login_info(self): def get_all(self): """Build and query all endpoint.""" _LOGGER.debug("get_all - Fetching all devices/products/actions from Hive API") - json_return = {} url = self.urls["base"] + self.urls["all"] - try: - info = self.request("GET", url) - if info is not None: - json_return.update({"original": info.status_code}) - json_return.update({"parsed": info.json()}) - _LOGGER.debug( - "get_all - All data fetch successful, status: %s", info.status_code - ) - else: - _LOGGER.error("Failed to get response from all endpoint") - except (OSError, RuntimeError, ZeroDivisionError, json.JSONDecodeError) as e: - _LOGGER.error("Failed to fetch all data: %s", str(e)) - self.error() - - return json_return + return self._call_endpoint("GET", url) def get_devices(self): """Call the get devices endpoint.""" url = self.urls["base"] + self.urls["devices"] - try: - response = self.request("GET", url) - self.json_return.update({"original": response.status_code}) - self.json_return.update({"parsed": response.json()}) - except (OSError, RuntimeError, ZeroDivisionError): - self.error() - - return self.json_return + return self._call_endpoint("GET", url) def get_products(self): """Call the get products endpoint.""" url = self.urls["base"] + self.urls["products"] - try: - response = self.request("GET", url) - self.json_return.update({"original": response.status_code}) - self.json_return.update({"parsed": response.json()}) - except (OSError, RuntimeError, ZeroDivisionError): - self.error() - - return self.json_return + return self._call_endpoint("GET", url) def get_actions(self): """Call the get actions endpoint.""" url = self.urls["base"] + self.urls["actions"] - try: - response = self.request("GET", url) - self.json_return.update({"original": response.status_code}) - self.json_return.update({"parsed": response.json()}) - except (OSError, RuntimeError, ZeroDivisionError): - self.error() - - return self.json_return + return self._call_endpoint("GET", url) def motion_sensor(self, sensor, fromepoch, toepoch): """Call a way to get motion sensor info.""" @@ -183,27 +161,13 @@ def motion_sensor(self, sensor, fromepoch, toepoch): + "&to=" + str(toepoch) ) - try: - response = self.request("GET", url) - self.json_return.update({"original": response.status_code}) - self.json_return.update({"parsed": response.json()}) - except (OSError, RuntimeError, ZeroDivisionError): - self.error() - - return self.json_return + return self._call_endpoint("GET", url) def get_weather(self, weather_url): """Call endpoint to get local weather from Hive API.""" t_url = self.urls["weather"] + weather_url url = t_url.replace(" ", "%20") - try: - response = self.request("GET", url) - self.json_return.update({"original": response.status_code}) - self.json_return.update({"parsed": response.json()}) - except (OSError, RuntimeError, ZeroDivisionError, ConnectionError): - self.error() - - return self.json_return + return self._call_endpoint("GET", url) def set_state(self, n_type, n_id, **kwargs): """Set the state of a Device.""" @@ -213,58 +177,27 @@ def set_state(self, n_type, n_id, **kwargs): n_type, kwargs, ) - jsc = ( - "{" - + ",".join( - ('"' + str(i) + '": "' + str(t) + '" ' for i, t in kwargs.items()) - ) - + "}" - ) - + jsc = json.dumps(kwargs) url = self.urls["base"] + self.urls["nodes"].format(n_type, n_id) - - try: - response = self.request("POST", url, jsc) - if response is not None: - self.json_return.update({"original": response.status_code}) - self.json_return.update({"parsed": response.json()}) - _LOGGER.debug( - "set_state - State set successfully for %s, status: %s", - n_id, - response.status_code, - ) - else: - _LOGGER.error("Failed to set state for %s - no response", n_id) - except ( - OSError, - RuntimeError, - ZeroDivisionError, - ConnectionError, - json.JSONDecodeError, - ) as e: - _LOGGER.error("Failed to set state for %s: %s", n_id, str(e)) - self.error() - - return self.json_return + return self._call_endpoint("POST", url, jsc) def set_action(self, n_id, data): """Set the state of a Action.""" jsc = data url = self.urls["base"] + self.urls["actions"] + "/" + n_id - try: - response = self.request("POST", url, jsc) - self.json_return.update({"original": response.status_code}) - self.json_return.update({"parsed": response.json()}) - except (OSError, RuntimeError, ZeroDivisionError, ConnectionError): - self.error() - - return self.json_return + return self._call_endpoint("POST", url, jsc) def error(self): """An error has occurred interacting with the Hive API.""" _LOGGER.error("API error occurred - returning error response") - self.json_return.update({"original": "Error making API call"}) - self.json_return.update({"parsed": "Error making API call"}) + error_return = { + "original": _ERROR_RESPONSE, + "parsed": _ERROR_RESPONSE, + } + # Kept in sync for backwards compatibility with callers that read + # the last error state off the instance. + self.json_return.update(error_return) + return error_return class UnknownConfig(Exception): diff --git a/src/api/hive_async_api.py b/src/api/hive_async_api.py index f88ff5e..e68d833 100644 --- a/src/api/hive_async_api.py +++ b/src/api/hive_async_api.py @@ -5,15 +5,21 @@ import logging import time -import requests -from aiohttp import ClientResponse, ClientSession, ClientTimeout, web_exceptions -from pyquery import PyQuery +from aiohttp import ( + ClientError, + ClientResponse, + ClientSession, + ClientTimeout, + web_exceptions, +) from ..helper.const import HTTP_FORBIDDEN, HTTP_UNAUTHORIZED from ..helper.hive_exceptions import FileInUse, HiveApiError, HiveAuthError, NoApiToken _LOGGER = logging.getLogger(__name__) +_REQUEST_ERRORS = (ClientError, OSError, RuntimeError, json.JSONDecodeError) + class HiveApiAsync: """Hive API Code.""" @@ -40,7 +46,17 @@ def __init__(self, hive_session=None, websession: ClientSession | None = None): "parsed": "No response to Hive API request", } self.session = hive_session - self.websession = ClientSession() if websession is None else websession + self.websession = websession + + def _get_websession(self) -> ClientSession: + """Return the shared ClientSession, creating it on first use. + + Created lazily so the session is constructed inside a running + event loop rather than in the synchronous constructor. + """ + if self.websession is None: + self.websession = ClientSession() + return self.websession async def request(self, method: str, url: str, **kwargs) -> ClientResponse: """Make a request.""" @@ -69,7 +85,7 @@ async def request(self, method: str, url: str, **kwargs) -> ClientResponse: timeout = ClientTimeout(total=self.timeout) req_start = time.monotonic() - async with self.websession.request( + async with self._get_websession().request( method, url, headers=headers, data=data, timeout=timeout ) as resp: resp_body = await resp.text() @@ -102,85 +118,39 @@ async def request(self, method: str, url: str, **kwargs) -> ClientResponse: ) raise HiveApiError - def get_login_info(self): - """Get login properties to make the login request.""" - url = "https://sso.hivehome.com/" - - data = requests.get(url=url, timeout=self.timeout) - html = PyQuery(data.content) - json_data = json.loads( - '{"' - + (html("script:first").text()) - .replace(",", ', "') - .replace("=", '":') - .replace("window.", "") - + "}" - ) - - login_data = {} - login_data.update({"UPID": json_data["HiveSSOPoolId"]}) - login_data.update({"CLIID": json_data["HiveSSOPublicCognitoClientId"]}) - login_data.update({"REGION": json_data["HiveSSOPoolId"]}) - return login_data - - async def get_all(self): - """Build and query all endpoint.""" - json_return = {} - url = self.urls["all"] + async def _call_endpoint(self, method: str, url: str, data=None) -> dict: + """Call an endpoint and return {"original": status, "parsed": json}.""" + json_return: dict = {} try: - resp = await self.request("get", url) + resp = await self.request(method, url, data=data) json_return.update({"original": resp.status}) json_return.update({"parsed": await resp.json(content_type=None)}) except asyncio.TimeoutError: - _LOGGER.warning("Hive API request timed out fetching all nodes.") + _LOGGER.warning("Hive API request timed out calling %s", url) raise - except (OSError, RuntimeError, ZeroDivisionError): + except _REQUEST_ERRORS: await self.error() return json_return + async def get_all(self): + """Build and query all endpoint.""" + return await self._call_endpoint("get", self.urls["all"]) + async def get_devices(self): """Call the get devices endpoint.""" - json_return = {} - url = self.urls["devices"] - try: - resp = await self.request("get", url) - json_return.update({"original": resp.status}) - json_return.update({"parsed": await resp.json(content_type=None)}) - except (OSError, RuntimeError, ZeroDivisionError): - await self.error() - - return json_return + return await self._call_endpoint("get", self.urls["devices"]) async def get_products(self): """Call the get products endpoint.""" - json_return = {} - url = self.urls["products"] - try: - resp = await self.request("get", url) - json_return.update({"original": resp.status}) - json_return.update({"parsed": await resp.json(content_type=None)}) - except (OSError, RuntimeError, ZeroDivisionError): - await self.error() - - return json_return + return await self._call_endpoint("get", self.urls["products"]) async def get_actions(self): """Call the get actions endpoint.""" - json_return = {} - url = self.urls["actions"] - try: - resp = await self.request("get", url) - json_return.update({"original": resp.status}) - json_return.update({"parsed": await resp.json(content_type=None)}) - except (OSError, RuntimeError, ZeroDivisionError): - await self.error() - - return json_return + return await self._call_endpoint("get", self.urls["actions"]) async def motion_sensor(self, sensor, fromepoch, toepoch): """Call a way to get motion sensor info.""" - json_return = {} url = ( self.base_url + "/products" @@ -193,65 +163,34 @@ async def motion_sensor(self, sensor, fromepoch, toepoch): + "&to=" + str(toepoch) ) - try: - resp = await self.request("get", url) - json_return.update({"original": resp.status}) - json_return.update({"parsed": await resp.json(content_type=None)}) - except (OSError, RuntimeError, ZeroDivisionError): - await self.error() - - return json_return + return await self._call_endpoint("get", url) async def get_weather(self, weather_url): """Call endpoint to get local weather from Hive API.""" - json_return = {} t_url = self.urls["weather"] + weather_url url = t_url.replace(" ", "%20") - try: - resp = await self.request("get", url) - json_return.update({"original": resp.status}) - json_return.update({"parsed": await resp.json(content_type=None)}) - except (OSError, RuntimeError, ZeroDivisionError, ConnectionError): - await self.error() - - return json_return + return await self._call_endpoint("get", url) async def set_state(self, n_type, n_id, **kwargs): """Set the state of a Device.""" _LOGGER.debug("set_state - Setting state for %s/%s: %s", n_type, n_id, kwargs) - json_return = {} jsc = json.dumps(kwargs) - url = self.urls["nodes"].format(n_type, n_id) try: await self.is_file_being_used() - resp = await self.request("post", url, data=jsc) - json_return["original"] = resp.status - json_return["parsed"] = await resp.json(content_type=None) except FileInUse: return {"original": "file"} - except (OSError, RuntimeError, ConnectionError): - await self.error() - - return json_return + return await self._call_endpoint("post", url, data=jsc) async def set_action(self, n_id, data): """Set the state of a Action.""" _LOGGER.debug("Setting action %s", n_id) - json_return = {} - jsc = data url = self.urls["actions"] + "/" + n_id try: await self.is_file_being_used() - resp = await self.request("put", url, data=jsc) - json_return["original"] = resp.status - json_return["parsed"] = await resp.json(content_type=None) except FileInUse: return {"original": "file"} - except (OSError, RuntimeError, ConnectionError): - await self.error() - - return json_return + return await self._call_endpoint("put", url, data=data) async def error(self): """An error has occurred interacting with the Hive API.""" diff --git a/src/api/hive_auth_async.py b/src/api/hive_auth_async.py index 0151f6c..6866a84 100644 --- a/src/api/hive_auth_async.py +++ b/src/api/hive_auth_async.py @@ -131,6 +131,25 @@ def generate_random_small_a(self): random_long_int = get_random(128) return random_long_int % self.big_n + def _new_srp_ephemeral(self) -> None: + """Generate a fresh SRP client ephemeral (a, A) pair. + + SRP ephemerals must not be reused across handshakes, so this is + called at the start of every authentication attempt. + """ + self.small_a_value = self.generate_random_small_a() + self.large_a_value = self.calculate_a() + + def _store_auth_result(self, result: dict) -> None: + """Store tokens and any new device keys from an AuthenticationResult.""" + auth_result = result["AuthenticationResult"] + self.access_token = auth_result["AccessToken"] + self.token_created = datetime.datetime.now() + if "NewDeviceMetadata" in auth_result: + self.device_group_key = auth_result["NewDeviceMetadata"]["DeviceGroupKey"] + self.device_key = auth_result["NewDeviceMetadata"]["DeviceKey"] + _LOGGER.debug("Device keys stored successfully.") + def calculate_a(self): """ Calculate the client's public value A. @@ -269,6 +288,7 @@ async def login(self): # noqa: PLR0912, PLR0915 # pylint: disable=too-many-sta if self.client is None: await self.async_init() + self._new_srp_ephemeral() auth_params = await self.get_auth_params() response = None result = None @@ -294,7 +314,12 @@ async def login(self): # noqa: PLR0912, PLR0915 # pylint: disable=too-many-sta _LOGGER.error("Cognito auth failed: cannot reach endpoint.") raise HiveApiError from err - if response["ChallengeName"] == self.PASSWORD_VERIFIER_CHALLENGE: + if "AuthenticationResult" in response: + _LOGGER.debug("login - Authenticated directly without a challenge.") + self._store_auth_result(response) + return response + + if response.get("ChallengeName") == self.PASSWORD_VERIFIER_CHALLENGE: _LOGGER.debug("login - Processing PASSWORD_VERIFIER challenge.") challenge_response = await self.process_challenge( response["ChallengeParameters"] @@ -328,20 +353,11 @@ async def login(self): # noqa: PLR0912, PLR0915 # pylint: disable=too-many-sta _LOGGER.debug("login - SRP auth challenge completed successfully.") if "AuthenticationResult" in result: - self.access_token = result["AuthenticationResult"]["AccessToken"] - self.token_created = datetime.datetime.now() - if "NewDeviceMetadata" in result["AuthenticationResult"]: - self.device_group_key = result["AuthenticationResult"][ - "NewDeviceMetadata" - ]["DeviceGroupKey"] - self.device_key = result["AuthenticationResult"][ - "NewDeviceMetadata" - ]["DeviceKey"] - _LOGGER.debug("login - Device keys stored successfully.") + self._store_auth_result(result) return result - challenge_name = response["ChallengeName"] + challenge_name = response.get("ChallengeName") _LOGGER.error("Unsupported Cognito challenge: %s", challenge_name) raise NotImplementedError(f"The {challenge_name} challenge is not supported") @@ -356,6 +372,7 @@ async def device_login(self): if self.client is None: await self.async_init() + self._new_srp_ephemeral() auth_params = await self.get_auth_params(is_device_login=True) _LOGGER.debug("device_login - Processing DEVICE_SRP_AUTH challenge.") @@ -419,20 +436,14 @@ async def sms_2fa(self, entered_code, challenge_parameters): ), ) if result and "AuthenticationResult" in result: - self.access_token = result["AuthenticationResult"]["AccessToken"] - self.token_created = datetime.datetime.now() - if "NewDeviceMetadata" in result["AuthenticationResult"]: - self.device_group_key = result["AuthenticationResult"][ - "NewDeviceMetadata" - ]["DeviceGroupKey"] - self.device_key = result["AuthenticationResult"][ - "NewDeviceMetadata" - ]["DeviceKey"] + self._store_auth_result(result) except botocore.exceptions.ClientError as err: code = (err.response or {}).get("Error", {}).get("Code", "") if code in ("NotAuthorizedException", "CodeMismatchException"): _LOGGER.error("2FA code rejected by Cognito.") raise HiveInvalid2FACode from err + _LOGGER.error("2FA failed: %s", code) + raise HiveApiError from err except botocore.exceptions.EndpointConnectionError as err: _LOGGER.error("2FA failed: cannot reach Cognito endpoint.") raise HiveApiError from err diff --git a/src/devices/heating.py b/src/devices/heating.py index de265ca..de458d7 100644 --- a/src/devices/heating.py +++ b/src/devices/heating.py @@ -48,6 +48,36 @@ async def get_max_temperature(self, device: Device): return self._get_product_state(device, "props", "maxHeat") return 32 + def _track_minmax(self, hive_id: str, temperature: float) -> None: + """Record today's and since-restart min/max temperatures for a device.""" + today = str(datetime.date(datetime.now())) + min_max = self.session.data.minMax.get(hive_id) + + if min_max is None: + self.session.data.minMax[hive_id] = { + "TodayMin": temperature, + "TodayMax": temperature, + "TodayDate": today, + "RestartMin": temperature, + "RestartMax": temperature, + } + return + + if min_max["TodayDate"] == today: + min_max["TodayMin"] = min(min_max["TodayMin"], temperature) + min_max["TodayMax"] = max(min_max["TodayMax"], temperature) + else: + min_max.update( + { + "TodayMin": temperature, + "TodayMax": temperature, + "TodayDate": today, + } + ) + + min_max["RestartMin"] = min(min_max["RestartMin"], temperature) + min_max["RestartMax"] = max(min_max["RestartMax"], temperature) + async def get_current_temperature(self, device: Device): """Get heating current temperature. @@ -75,41 +105,7 @@ async def get_current_temperature(self, device: Device): ) return None - if device.hive_id in self.session.data.minMax: - if self.session.data.minMax[device.hive_id]["TodayDate"] == str( - datetime.date(datetime.now()) - ): - self.session.data.minMax[device.hive_id]["TodayMin"] = min( - self.session.data.minMax[device.hive_id]["TodayMin"], state - ) - - self.session.data.minMax[device.hive_id]["TodayMax"] = max( - self.session.data.minMax[device.hive_id]["TodayMax"], state - ) - else: - data = { - "TodayMin": state, - "TodayMax": state, - "TodayDate": str(datetime.date(datetime.now())), - } - self.session.data.minMax[device.hive_id].update(data) - - self.session.data.minMax[device.hive_id]["RestartMin"] = min( - self.session.data.minMax[device.hive_id]["RestartMin"], state - ) - - self.session.data.minMax[device.hive_id]["RestartMax"] = max( - self.session.data.minMax[device.hive_id]["RestartMax"], state - ) - else: - data = { - "TodayMin": state, - "TodayMax": state, - "TodayDate": str(datetime.date(datetime.now())), - "RestartMin": state, - "RestartMax": state, - } - self.session.data.minMax[device.hive_id] = data + self._track_minmax(device.hive_id, state) final = round(state, 1) except KeyError as e: @@ -285,7 +281,18 @@ async def set_boost_on(self, device: Device, mins: str, temp: float): """ min_temp = await self.get_min_temperature(device) max_temp = await self.get_max_temperature(device) - if not (int(mins) > 0 and min_temp <= int(temp) <= max_temp): + try: + mins_value = int(mins) + temp_value = float(temp) + except (ValueError, TypeError): + _LOGGER.warning( + "set_boost_on - Invalid boost inputs for %s: mins=%r temp=%r", + device.ha_name, + mins, + temp, + ) + return None + if not (mins_value > 0 and min_temp <= temp_value <= max_temp): return None _LOGGER.debug( "set_boost_on - Setting heating boost ON for %s: %s mins at %s degrees.", diff --git a/src/devices/light.py b/src/devices/light.py index 087cd59..d48dc91 100644 --- a/src/devices/light.py +++ b/src/devices/light.py @@ -63,9 +63,9 @@ async def get_brightness(self, device: Device): data = self.session.data.products[device.hive_id] state = data["state"]["brightness"] final = int((state / 100) * 255) - except KeyError as e: + except (KeyError, TypeError) as e: _LOGGER.error( - "KeyError getting light brightness for %s: %s", device_name, str(e) + "Error getting light brightness for %s: %s", device_name, str(e) ) return final diff --git a/src/session/__init__.py b/src/session/__init__.py index aefa568..bb9f192 100644 --- a/src/session/__init__.py +++ b/src/session/__init__.py @@ -77,8 +77,9 @@ def __init__( async def close(self) -> None: """Close the underlying aiohttp ClientSession if we own it.""" - if self._owns_websession and not self.api.websession.closed: - await self.api.websession.close() + websession = self.api.websession + if self._owns_websession and websession is not None and not websession.closed: + await websession.close() async def __aenter__(self): return self diff --git a/src/session/auth.py b/src/session/auth.py index 82292bd..4f9d67d 100644 --- a/src/session/auth.py +++ b/src/session/auth.py @@ -70,10 +70,11 @@ async def _retry_with_backoff( raise except Exception as err: # pylint: disable=broad-except last_err = err - exc_type = reraise_as or ( - type(last_err) if last_err is not None else RuntimeError - ) - raise exc_type() from last_err # pylint: disable=broad-exception-raised + if reraise_as is not None: + raise reraise_as() from last_err + if last_err is not None: + raise last_err + raise RuntimeError("Retry attempts exhausted without capturing an error") async def update_tokens(self, tokens: dict, update_expiry_time: bool = True): """Update session tokens. @@ -91,10 +92,12 @@ async def update_tokens(self, tokens: dict, update_expiry_time: bool = True): ) if "AuthenticationResult" in tokens: data = tokens.get("AuthenticationResult") or {} - self.tokens.token_data.update({"token": data["IdToken"]}) + if "IdToken" in data: + self.tokens.token_data.update({"token": data["IdToken"]}) if "RefreshToken" in data: self.tokens.token_data.update({"refreshToken": data["RefreshToken"]}) - self.tokens.token_data.update({"accessToken": data["AccessToken"]}) + if "AccessToken" in data: + self.tokens.token_data.update({"accessToken": data["AccessToken"]}) if update_expiry_time: self.tokens.token_created = datetime.now() elif "token" in tokens: @@ -102,7 +105,8 @@ async def update_tokens(self, tokens: dict, update_expiry_time: bool = True): self.tokens.token_data.update({"token": data["token"]}) if "refreshToken" in data: self.tokens.token_data.update({"refreshToken": data["refreshToken"]}) - self.tokens.token_data.update({"accessToken": data["accessToken"]}) + if "accessToken" in data: + self.tokens.token_data.update({"accessToken": data["accessToken"]}) if update_expiry_time: self.tokens.token_created = datetime.now() diff --git a/src/session/discovery.py b/src/session/discovery.py index 6d39c64..aa13ad8 100644 --- a/src/session/discovery.py +++ b/src/session/discovery.py @@ -148,10 +148,15 @@ async def start_session(self, config: dict | None = None): self.auth.password = config["password"] if "device_data" in config and not self.config.file: - self.auth.device_group_key = config["device_data"][0] - self.auth.device_key = config["device_data"][1] - self.auth.device_password = config["device_data"][2] device_data = config["device_data"] + if len(device_data) < EXPECTED_DEVICE_DATA_LENGTH: + raise HiveUnknownConfiguration( + "device_data must contain device_group_key, " + "device_key and device_password" + ) + self.auth.device_group_key = device_data[0] + self.auth.device_key = device_data[1] + self.auth.device_password = device_data[2] if len(device_data) > EXPECTED_DEVICE_DATA_LENGTH: token_created = device_data[3] if token_created: diff --git a/src/session/polling.py b/src/session/polling.py index 283e1aa..e9e9784 100644 --- a/src/session/polling.py +++ b/src/session/polling.py @@ -181,7 +181,7 @@ async def get_devices(self, _n_id: str): # pylint: disable=too-many-locals,too- for hive_type_key in api_resp_p: if hive_type_key == "user": self.data.user = api_resp_p[hive_type_key] - self.config.user_id = api_resp_p[hive_type_key]["id"] + self.config.user_id = api_resp_p[hive_type_key].get("id") if hive_type_key == "products": for a_product in api_resp_p[hive_type_key]: tmp_products.update({a_product["id"]: a_product}) @@ -228,6 +228,7 @@ async def get_devices(self, _n_id: str): # pylint: disable=too-many-locals,too- HiveApiError, ConnectionError, HTTPException, + KeyError, ) as err: _LOGGER.error("Failed to fetch devices: %s", err) self.config.last_update = ( diff --git a/tests/module/test_hub.py b/tests/module/test_hub.py index f3d5f33..06b61d7 100644 --- a/tests/module/test_hub.py +++ b/tests/module/test_hub.py @@ -128,11 +128,13 @@ async def test_context_manager_aenter_returns_self(self): assert hive is not None async def test_close_calls_websession_close(self): - """__aexit__ closes the underlying aiohttp websession.""" + """__aexit__ closes the lazily created aiohttp websession.""" async with Hive( username="test@example.com", password="pass", # pragma: allowlist secret ) as hive: - ws = hive.api.websession + # The websession is created lazily, on first use inside the loop. + assert hive.api.websession is None + ws = hive.api._get_websession() # After context exit the session should be closed assert ws.closed diff --git a/tests/module/test_session.py b/tests/module/test_session.py index 3de0c5a..8713aa3 100644 --- a/tests/module/test_session.py +++ b/tests/module/test_session.py @@ -178,4 +178,5 @@ async def always_fails(): with patch("asyncio.sleep", new=AsyncMock()): with pytest.raises(Exception) as exc_info: await hive._retry_with_backoff(always_fails, delays=(0, 0)) # pylint: disable=protected-access - assert "permanent failure" in str(exc_info.value.__cause__) + # The original exception instance propagates unchanged. + assert "permanent failure" in str(exc_info.value) diff --git a/tests/unit/test_heating.py b/tests/unit/test_heating.py index 708388e..56c13ca 100644 --- a/tests/unit/test_heating.py +++ b/tests/unit/test_heating.py @@ -189,6 +189,31 @@ async def test_returns_working_state(self): assert result is True +class TestSetBoostOnValidation: + """set_boost_on input validation must reject bad values with None.""" + + async def test_non_numeric_mins_returns_none(self): + climate = _make_climate(products={"heat-1": {"type": "heating"}}) + result = await climate.set_boost_on(_make_device(), "abc", 20) + assert result is None + + async def test_non_numeric_temp_returns_none(self): + climate = _make_climate(products={"heat-1": {"type": "heating"}}) + result = await climate.set_boost_on(_make_device(), "30", "hot") + assert result is None + + async def test_temp_fraction_above_max_returns_none(self): + """32.9 exceeds the 32 maximum and must not be truncated past validation.""" + climate = _make_climate(products={"heat-1": {"type": "heating"}}) + result = await climate.set_boost_on(_make_device(), "30", 32.9) + assert result is None + + async def test_valid_boost_executes_state_change(self): + climate = _make_climate(products={"heat-1": {"type": "heating"}}) + result = await climate.set_boost_on(_make_device(), "30", 22) + assert result is True + + class TestSetBoostOff: async def test_not_in_products_returns_false(self): """Device hive_id not present in products returns False.""" diff --git a/tests/unit/test_hive_api.py b/tests/unit/test_hive_api.py index 1cd1093..6e272bd 100644 --- a/tests/unit/test_hive_api.py +++ b/tests/unit/test_hive_api.py @@ -152,6 +152,27 @@ def test_request_passes_timeout(self): class TestGetLoginInfo: + def test_tls_verification_is_not_disabled(self): + """The SSO bootstrap request must not pass verify=False.""" + api = _make_api() + html_content = ( + b"" + ) + mock_resp = MagicMock() + mock_resp.content = html_content + mock_resp.status_code = 200 + + with patch( + "apyhiveapi.api.hive_api.requests.get", return_value=mock_resp + ) as mock_get: + api.get_login_info() + + _, call_kwargs = mock_get.call_args + assert call_kwargs.get("verify", True) is not False + def test_successful_parse_returns_login_data(self): """Parses HiveSSOPoolId and HiveSSOPublicCognitoClientId from the SSO page.""" api = _make_api() @@ -230,14 +251,13 @@ def test_successful_returns_original_and_parsed(self): assert result["original"] == 200 assert result["parsed"] == payload - def test_none_response_logs_error_and_returns_empty(self): + def test_none_response_logs_error_and_returns_no_response_marker(self): """When request returns None the method should not crash.""" api = _make_api() with patch.object(api, "request", return_value=None): result = api.get_all() - # No keys populated — dict remains empty - assert "original" not in result + assert result["original"] == "No response to Hive API request" def test_os_error_calls_error_method(self): api = _make_api() @@ -471,8 +491,7 @@ def test_none_response_logs_error_no_crash(self): with patch.object(api, "request", return_value=None): result = api.set_state("heating", "node-1", mode="MANUAL") - # json_return stays at default (unchanged from init defaults) - assert result is api.json_return + assert result["original"] == "No response to Hive API request" def test_os_error_calls_error(self): api = _make_api() @@ -513,6 +532,28 @@ def test_kwargs_serialised_into_jsc(self): assert "target" in jsc_arg assert "21" in jsc_arg + def test_payload_is_valid_json_with_native_types(self): + """The payload must round-trip through json.loads with types intact.""" + api = _make_api() + mock_resp = _make_mock_response(200, json_data={}) + + with patch.object(api, "request", return_value=mock_resp) as mock_req: + api.set_state("heating", "n1", mode="SCHEDULE", target=21.5) + + jsc_arg = mock_req.call_args[0][2] + assert json.loads(jsc_arg) == {"mode": "SCHEDULE", "target": 21.5} + + def test_payload_with_quotes_is_valid_json(self): + """Values containing quotes must not break or inject into the JSON.""" + api = _make_api() + mock_resp = _make_mock_response(200, json_data={}) + + with patch.object(api, "request", return_value=mock_resp) as mock_req: + api.set_state("heating", "n1", name='say "hi", "extra": "injected') + + jsc_arg = mock_req.call_args[0][2] + assert json.loads(jsc_arg) == {"name": 'say "hi", "extra": "injected'} + # --------------------------------------------------------------------------- # Tests: HiveApi.set_action @@ -574,6 +615,52 @@ def test_runtime_error_calls_error(self): assert api.json_return["original"] == "Error making API call" +# --------------------------------------------------------------------------- +# Tests: result isolation between calls +# --------------------------------------------------------------------------- + + +class TestResultIsolation: + def test_results_are_independent_between_calls(self): + """A later call must not mutate the dict returned by an earlier call.""" + api = _make_api() + resp_devices = _make_mock_response(200, json_data=[{"id": "dev1"}]) + resp_products = _make_mock_response(200, json_data=[{"id": "prod1"}]) + + with patch.object(api, "request", side_effect=[resp_devices, resp_products]): + devices = api.get_devices() + products = api.get_products() + + assert devices is not products + assert devices["parsed"] == [{"id": "dev1"}] + assert products["parsed"] == [{"id": "prod1"}] + + def test_error_call_does_not_corrupt_previous_result(self): + """An error in a later call must not overwrite an earlier result.""" + api = _make_api() + resp_devices = _make_mock_response(200, json_data=[{"id": "dev1"}]) + + with patch.object(api, "request", side_effect=[resp_devices, OSError("down")]): + devices = api.get_devices() + failed = api.get_products() + + assert devices["original"] == 200 + assert devices["parsed"] == [{"id": "dev1"}] + assert failed["original"] == "Error making API call" + + def test_set_state_none_response_does_not_return_stale_data(self): + """set_state with no response must not surface a previous call's payload.""" + api = _make_api() + resp_devices = _make_mock_response(200, json_data=[{"id": "dev1"}]) + + with patch.object(api, "request", side_effect=[resp_devices, None]): + api.get_devices() + result = api.set_state("heating", "n1", mode="MANUAL") + + assert result["parsed"] != [{"id": "dev1"}] + assert result["original"] == "No response to Hive API request" + + # --------------------------------------------------------------------------- # Tests: HiveApi.error # --------------------------------------------------------------------------- diff --git a/tests/unit/test_hive_async_api.py b/tests/unit/test_hive_async_api.py index c063f31..92d1027 100644 --- a/tests/unit/test_hive_async_api.py +++ b/tests/unit/test_hive_async_api.py @@ -3,6 +3,7 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch +import aiohttp import pytest from aiohttp import web_exceptions from apyhiveapi.api.hive_async_api import HiveApiAsync @@ -314,14 +315,26 @@ async def test_not_file_mode_does_not_raise(self): class TestInit: - async def test_default_websession_created_when_none_passed(self): + def test_no_websession_created_at_init(self): + """No ClientSession is created in the sync constructor.""" session = MagicMock() session.tokens = MagicMock() session.tokens.token_data = {"token": "tok"} session.config = MagicMock() api = HiveApiAsync(hive_session=session) - assert api.websession is not None - await api.websession.close() + assert api.websession is None + + def test_websession_created_lazily_and_cached(self): + """The first _get_websession() call creates and caches a ClientSession.""" + session = MagicMock() + api = HiveApiAsync(hive_session=session) + with patch("apyhiveapi.api.hive_async_api.ClientSession") as mock_session_cls: + first = api._get_websession() + second = api._get_websession() + mock_session_cls.assert_called_once() + assert first is mock_session_cls.return_value + assert second is first + assert api.websession is first def test_custom_websession_is_used(self): session = MagicMock() @@ -365,66 +378,6 @@ async def test_422_logs_and_raises_hive_api_error(self): await api.request("get", "https://beekeeper.hivehome.com/1.0/devices") -class TestGetLoginInfo: - """Cover lines 112-129: get_login_info() parses HTML and returns login dict.""" - - def test_returns_upid_cliid_region(self): - """Successful fetch returns correct keys from parsed HTML.""" - html_content = ( - b"" - ) - mock_response = MagicMock() - mock_response.content = html_content - api = _make_api() - with patch( - "apyhiveapi.api.hive_async_api.requests.get", return_value=mock_response - ): - result = api.get_login_info() - assert result["UPID"] == "eu-west-1_abc123" - assert result["CLIID"] == "client-xyz" - assert result["REGION"] == "eu-west-1_abc123" - - def test_makes_request_to_sso_url(self): - """Verifies requests.get is called with the SSO URL.""" - html_content = ( - b"" - ) - mock_response = MagicMock() - mock_response.content = html_content - api = _make_api() - with patch( - "apyhiveapi.api.hive_async_api.requests.get", return_value=mock_response - ) as mock_get: - api.get_login_info() - mock_get.assert_called_once_with( - url="https://sso.hivehome.com/", timeout=api.timeout - ) - - def test_uses_first_script_tag(self): - """PyQuery selects the first script — extra scripts are ignored.""" - html_content = ( - b"" - b'' - ) - mock_response = MagicMock() - mock_response.content = html_content - api = _make_api() - with patch( - "apyhiveapi.api.hive_async_api.requests.get", return_value=mock_response - ): - result = api.get_login_info() - assert result["UPID"] == "eu-west-1_first" - - class TestMotionSensorBranches: """Cover lines 215-235: motion_sensor() success and error paths.""" @@ -476,12 +429,12 @@ async def test_runtime_error_raises_http_error(self): with pytest.raises(web_exceptions.HTTPError): await api.motion_sensor(sensor, fromepoch=1000, toepoch=2000) - async def test_zero_division_raises_http_error(self): - """ZeroDivisionError inside the try block causes error() → HTTPError.""" + async def test_client_error_raises_http_error(self): + """aiohttp.ClientError inside the try block causes error() → HTTPError.""" api = _make_api(status=200) api.urls["base"] = "" sensor = {"type": "motionsensor", "id": "sensor-003"} - api.websession.request.side_effect = ZeroDivisionError() + api.websession.request.side_effect = aiohttp.ClientError() with pytest.raises(web_exceptions.HTTPError): await api.motion_sensor(sensor, fromepoch=1000, toepoch=2000) @@ -541,10 +494,10 @@ async def test_runtime_error_raises_http_error(self): with pytest.raises(web_exceptions.HTTPError): await api.get_weather("?lat=51.5") - async def test_zero_division_raises_http_error(self): - """ZeroDivisionError inside the try block causes error() → HTTPError.""" + async def test_client_error_raises_http_error(self): + """aiohttp.ClientError inside the try block causes error() → HTTPError.""" api = _make_api(status=200) - api.websession.request.side_effect = ZeroDivisionError() + api.websession.request.side_effect = aiohttp.ClientError() with pytest.raises(web_exceptions.HTTPError): await api.get_weather("?lat=51.5") diff --git a/tests/unit/test_hive_auth_async.py b/tests/unit/test_hive_auth_async.py index bb82643..9b03a7f 100644 --- a/tests/unit/test_hive_auth_async.py +++ b/tests/unit/test_hive_auth_async.py @@ -208,6 +208,49 @@ async def test_user_not_found_raises_invalid_username(self): with pytest.raises(HiveInvalidUsername): await auth.login() + @pytest.mark.asyncio + async def test_direct_authentication_without_challenge_stores_token(self): + """A response with AuthenticationResult and no ChallengeName must not crash.""" + auth = await _make_auth() + auth_result = {"AuthenticationResult": {"AccessToken": "direct-tok"}} + auth.loop.run_in_executor.return_value = auth_result + + result = await auth.login() + + assert result is auth_result + assert auth.access_token == "direct-tok" + + @pytest.mark.asyncio + async def test_login_regenerates_srp_ephemeral_each_attempt(self): + """Each login() must use a fresh SRP (a, A) ephemeral pair.""" + auth = await _make_auth() + auth.loop.run_in_executor.return_value = { + "AuthenticationResult": {"AccessToken": "tok"} + } + + initial_a = auth.large_a_value + await auth.login() + second_a = auth.large_a_value + await auth.login() + third_a = auth.large_a_value + + assert len({initial_a, second_a, third_a}) == 3 + + @pytest.mark.asyncio + async def test_device_login_regenerates_srp_ephemeral(self): + """device_login() must use a fresh SRP (a, A) ephemeral pair.""" + auth = await _make_auth(device_key="dk-1", device_group_key="grp-1") + auth.device_password = "dev-pass" # pragma: allowlist secret + auth.loop.run_in_executor.return_value = {"ChallengeParameters": {}} + + initial_a = auth.large_a_value + with patch.object( + auth, "process_device_challenge", new=AsyncMock(return_value={}) + ): + await auth.device_login() + + assert auth.large_a_value != initial_a + @pytest.mark.asyncio async def test_endpoint_error_on_initiate_raises_api_error(self): auth = await _make_auth() @@ -1240,19 +1283,19 @@ async def test_wrong_name_endpoint_error_raises_api_error(self): await auth.device_login() -class TestSms2faSwallowedClientError: - """Arc 424->435: ClientError caught in sms_2fa with unrecognised class name.""" +class TestSms2faUnrecognisedClientError: + """An unrecognised ClientError in sms_2fa must surface as HiveApiError.""" - async def test_other_client_error_is_swallowed_returns_none(self): - """Non-matching ClientError is swallowed; result stays None (returned).""" + async def test_other_client_error_raises_hive_api_error(self): + """A ClientError that is not a 2FA rejection must not be swallowed.""" auth = await _make_auth() - wrong_cls = type("OtherError", (botocore.exceptions.ClientError,), {}) - wrong_err = wrong_cls({"Error": {"Code": "OtherError", "Message": "msg"}}, "op") - auth.loop.run_in_executor = AsyncMock(side_effect=wrong_err) + auth.loop.run_in_executor = AsyncMock( + side_effect=_named_client_error("LimitExceededException") + ) - result = await auth.sms_2fa("123456", {"Session": "sess-xyz"}) - assert result is None + with pytest.raises(HiveApiError): + await auth.sms_2fa("123456", {"Session": "sess-xyz"}) class TestSms2faSwallowedEndpointError: diff --git a/tests/unit/test_light.py b/tests/unit/test_light.py index 6ad1a6f..a6ff385 100644 --- a/tests/unit/test_light.py +++ b/tests/unit/test_light.py @@ -270,6 +270,23 @@ class StubLight(HiveLight): assert result == 127 +class TestGetBrightnessNullValue: + """A null brightness in the API payload must not raise TypeError.""" + + async def test_null_brightness_returns_none(self): + session = _make_session( + products={ + "light-1": { + "state": {"status": "ON", "brightness": None}, + "props": {}, + } + }, + ) + light = Light(session=session) + result = await light.get_brightness(_make_device()) + assert result is None + + # =========================================================================== # Migrated from test_remaining_branches.py # =========================================================================== diff --git a/tests/unit/test_session_auth.py b/tests/unit/test_session_auth.py index 5f11837..87e9967 100644 --- a/tests/unit/test_session_auth.py +++ b/tests/unit/test_session_auth.py @@ -93,6 +93,84 @@ async def test_auth_result_with_update_expiry_true_sets_token_created(self): await s.update_tokens(AUTH_RESULT, update_expiry_time=True) assert s.tokens.token_created > before + async def test_auth_result_missing_id_token_does_not_raise(self): + """AuthenticationResult with only AccessToken (e.g. file mode) must not crash.""" + s = _make_stub() + payload = {"AuthenticationResult": {"AccessToken": "only-access"}} + await s.update_tokens(payload) + assert s.tokens.token_data["accessToken"] == "only-access" + # IdToken absent — the session token must not have been overwritten + assert s.tokens.token_data["token"] == "" + + async def test_flat_dict_missing_access_token_does_not_raise(self): + """A flat token dict without accessToken must not crash.""" + s = _make_stub() + flat = {"token": "t-only"} + await s.update_tokens(flat) + assert s.tokens.token_data["token"] == "t-only" + assert s.tokens.token_data["accessToken"] == "" + + async def test_auth_result_missing_access_token_does_not_raise(self): + """AuthenticationResult with only IdToken must not crash.""" + s = _make_stub() + payload = {"AuthenticationResult": {"IdToken": "only-id"}} + await s.update_tokens(payload) + assert s.tokens.token_data["token"] == "only-id" + assert s.tokens.token_data["accessToken"] == "" + + +# --------------------------------------------------------------------------- +# _retry_with_backoff — re-raise semantics +# --------------------------------------------------------------------------- + + +class _NeedsArgsError(Exception): + """Exception type that cannot be constructed without arguments.""" + + def __init__(self, first, second): + super().__init__(f"{first}/{second}") + + +class TestRetryWithBackoffReraise: + """Without reraise_as, the original exception instance must propagate.""" + + async def test_original_exception_instance_propagates(self): + """The last caught error is re-raised as-is, not re-instantiated.""" + s = _make_stub() + original = _NeedsArgsError("a", "b") + + async def _always_fail(): + raise original + + with pytest.raises(_NeedsArgsError) as excinfo: + await s._retry_with_backoff(_always_fail, delays=(0,)) + + assert excinfo.value is original + + async def test_empty_delays_raises_runtime_error(self): + """With no attempts configured the defensive fallback raises RuntimeError.""" + s = _make_stub() + + async def _never_called(): + raise AssertionError("should not run") + + with pytest.raises(RuntimeError, match="exhausted"): + await s._retry_with_backoff(_never_called, delays=()) + + async def test_reraise_as_still_translates_exception_type(self): + """When reraise_as is given the error is translated with chaining.""" + s = _make_stub() + + async def _always_fail(): + raise ValueError("boom") + + with pytest.raises(HiveReauthRequired) as excinfo: + await s._retry_with_backoff( + _always_fail, delays=(0,), reraise_as=HiveReauthRequired + ) + + assert isinstance(excinfo.value.__cause__, ValueError) + # --------------------------------------------------------------------------- # _handle_device_login_challenge — extra branch diff --git a/tests/unit/test_session_close.py b/tests/unit/test_session_close.py index 17e66d0..b66df73 100644 --- a/tests/unit/test_session_close.py +++ b/tests/unit/test_session_close.py @@ -20,6 +20,15 @@ async def test_close_calls_websession_close_when_not_already_closed(self): session.api.websession.close.assert_called_once() + async def test_close_with_no_websession_does_not_raise(self): + """close() is a no-op when the lazy websession was never created.""" + session = object.__new__(HiveSession) + session.api = MagicMock() + session.api.websession = None + session._owns_websession = True + + await session.close() + async def test_close_skips_websession_close_when_already_closed(self): """close() does NOT call websession.close() when websession is already closed.""" session = object.__new__(HiveSession) diff --git a/tests/unit/test_session_discovery.py b/tests/unit/test_session_discovery.py index 0815bbd..b889fb7 100644 --- a/tests/unit/test_session_discovery.py +++ b/tests/unit/test_session_discovery.py @@ -142,6 +142,18 @@ async def test_with_device_data_3_items_sets_auth_keys(self): assert s.auth.device_key == "dev-key" assert s.auth.device_password == "dev-pass" # pragma: allowlist secret + async def test_with_device_data_too_short_raises_unknown_configuration(self): + """device_data with fewer than 3 items raises HiveUnknownConfiguration.""" + s = _make_stub() + s.config.file = False + with pytest.raises(HiveUnknownConfiguration): + await s.start_session( + { + "tokens": {}, + "device_data": ["grp-key", "dev-key"], + } + ) + async def test_with_device_data_4_items_sets_token_created(self): """4-item device_data with a token_created timestamp sets tokens.token_created.""" s = _make_stub() diff --git a/tests/unit/test_session_get_devices.py b/tests/unit/test_session_get_devices.py index 9042215..b284b1e 100644 --- a/tests/unit/test_session_get_devices.py +++ b/tests/unit/test_session_get_devices.py @@ -94,6 +94,39 @@ async def test_file_mode_does_not_call_api(self): p.hive_refresh_tokens.assert_not_called() +# --------------------------------------------------------------------------- +# get_devices — malformed API responses +# --------------------------------------------------------------------------- + + +class TestGetDevicesMalformedResponse: + """Hostile/partial response shapes must not escape as raw KeyError.""" + + async def test_user_without_id_still_succeeds(self): + """A user object with no 'id' key must not crash the poll.""" + p = _make_stub() + p.tokens = MagicMock() + p.api.get_all = AsyncMock( + return_value={"original": 200, "parsed": {"user": {"name": "x"}}} + ) + result = await p.get_devices("No_ID") + assert result is True + assert p.config.user_id is None + + async def test_product_without_id_returns_false(self): + """A product entry with no 'id' key fails the poll gracefully.""" + p = _make_stub() + p.tokens = MagicMock() + p.api.get_all = AsyncMock( + return_value={ + "original": 200, + "parsed": {"products": [{"type": "heating"}]}, + } + ) + result = await p.get_devices("No_ID") + assert result is False + + # --------------------------------------------------------------------------- # get_devices — tokens path # --------------------------------------------------------------------------- From 7908dbd72b137481fea85bbb493a203d58c10177 Mon Sep 17 00:00:00 2001 From: Khole Jones <29937485+KJonline@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:58:01 +0100 Subject: [PATCH 29/29] refactor: remove unused _BOOST_MINS constant from test_hotwater.py --- tests/unit/test_hotwater.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_hotwater.py b/tests/unit/test_hotwater.py index 0696510..5da4e33 100644 --- a/tests/unit/test_hotwater.py +++ b/tests/unit/test_hotwater.py @@ -11,7 +11,6 @@ _ON_MODE = "ON" _OFF_MODE = "OFF" _BOOST_MODE = "BOOST" -_BOOST_MINS = 30 def _make_hotwater(products=None, devices=None):