From 2bab3cf60af9d3542557f670170f59c07d517d26 Mon Sep 17 00:00:00 2001 From: markov Date: Fri, 13 Mar 2026 22:47:19 +0100 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20E2E=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20Team=20Board=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Полный набор тестов для всех модулей API - test_auth.py: аутентификация и JWT токены - test_members.py: CRUD участников, агенты, токены - test_projects.py: CRUD проектов, участники проектов - test_tasks.py: CRUD задач, этапы, назначения, зависимости - test_chat.py: сообщения, комментарии, mentions - test_files.py: upload/download файлов проектов - test_labels.py: CRUD лейблов, привязка к задачам - test_websocket.py: WebSocket подключения и события - test_streaming.py: агентный стриминг через WebSocket - conftest.py: фикстуры для подключения к API - requirements.txt: зависимости pytest, httpx, websockets - pytest.ini: настройки asyncio для pytest --- .../conftest.cpython-312-pytest-8.2.2.pyc | Bin 0 -> 12016 bytes .../test_auth.cpython-312-pytest-8.2.2.pyc | Bin 0 -> 19453 bytes .../test_chat.cpython-312-pytest-8.2.2.pyc | Bin 0 -> 35371 bytes .../test_files.cpython-312-pytest-8.2.2.pyc | Bin 0 -> 42233 bytes .../test_labels.cpython-312-pytest-8.2.2.pyc | Bin 0 -> 41537 bytes .../test_members.cpython-312-pytest-8.2.2.pyc | Bin 0 -> 40965 bytes ...test_projects.cpython-312-pytest-8.2.2.pyc | Bin 0 -> 43284 bytes ...est_streaming.cpython-312-pytest-8.2.2.pyc | Bin 0 -> 43909 bytes .../test_tasks.cpython-312-pytest-8.2.2.pyc | Bin 0 -> 69660 bytes ...est_websocket.cpython-312-pytest-8.2.2.pyc | Bin 0 -> 36556 bytes tests/conftest.py | 174 ++++++ tests/pytest.ini | 3 + tests/requirements.txt | 5 + tests/test_auth.py | 117 ++++ tests/test_chat.py | 315 ++++++++++ tests/test_files.py | 404 +++++++++++++ tests/test_labels.py | 352 ++++++++++++ tests/test_members.py | 287 ++++++++++ tests/test_projects.py | 298 ++++++++++ tests/test_streaming.py | 538 ++++++++++++++++++ tests/test_tasks.py | 487 ++++++++++++++++ tests/test_websocket.py | 411 +++++++++++++ 22 files changed, 3391 insertions(+) create mode 100644 tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc create mode 100644 tests/__pycache__/test_auth.cpython-312-pytest-8.2.2.pyc create mode 100644 tests/__pycache__/test_chat.cpython-312-pytest-8.2.2.pyc create mode 100644 tests/__pycache__/test_files.cpython-312-pytest-8.2.2.pyc create mode 100644 tests/__pycache__/test_labels.cpython-312-pytest-8.2.2.pyc create mode 100644 tests/__pycache__/test_members.cpython-312-pytest-8.2.2.pyc create mode 100644 tests/__pycache__/test_projects.cpython-312-pytest-8.2.2.pyc create mode 100644 tests/__pycache__/test_streaming.cpython-312-pytest-8.2.2.pyc create mode 100644 tests/__pycache__/test_tasks.cpython-312-pytest-8.2.2.pyc create mode 100644 tests/__pycache__/test_websocket.cpython-312-pytest-8.2.2.pyc create mode 100644 tests/conftest.py create mode 100644 tests/pytest.ini create mode 100644 tests/requirements.txt create mode 100644 tests/test_auth.py create mode 100644 tests/test_chat.py create mode 100644 tests/test_files.py create mode 100644 tests/test_labels.py create mode 100644 tests/test_members.py create mode 100644 tests/test_projects.py create mode 100644 tests/test_streaming.py create mode 100644 tests/test_tasks.py create mode 100644 tests/test_websocket.py diff --git a/tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/conftest.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ebcb75ddbd49b19867db506848eac7122f3b6a9 GIT binary patch literal 12016 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

B1XEU$qghD4o+^A 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ξ96-dmCYR*FLH0RO!{*jcTR=wDg^GTsdR8T)cWGl7mj`| zdaA!#W9D?5>;={>|M;2UZV(i;p~QPdD#vg_GPO7Q-eC%S#j==TwQ}mc9h^kgUpG!MtcUk@Gc8q)9qWiuj=QCHi71 z;(nURIQnL9^y3tf^`}S1Audtl>e>a+vT$cv!GnH&D@Ib8ZLY0xyDIH@V~#S0X>nE# zcw69ixZ)a_t#iufme$(B zBcstj0gIj`OIMG?jtJ=2Mwzx}Tz4RV;E7RwS9%kj!HruRm)5)M4tbmDU0qtPR(L-G z$~AO)f4gzVh1j|E=N^4&*QKUNzH!Iz1sIW;)4_U{$E8d1e$wdgLEcmvGs1E*No1UND!Fx;I6EQ)185H6q;O5Q1%P5D;(#ST2`3S;)53+@j1pfsQ zO-o1jea?04oeJGu2;5!MS=g}g^RF8kfx?qpFE#F%YS?k=;BVfqU7%sRX=UrSdo+FD zzO~Ts_O0g5?F&LQ(k?loQSjjxjY>{+lXAMJpVS9sQozuZ8{VqQKIG+&Xl1sZJ zIh1R3VIuYU2WtXg(|1uEeS)rHl$;37#}+GZN{-D;;znZpNy!lt#*+hyl;qiskzQ(F zp*wT~IxjIG(tZ5swpnyT0_vfSy3FBm;X7Sq$bSK2h+U)T6do-Xf>@43priINmzu$w|@J+@EU3O&2?J-e|U+qHUZpH+`F z>^40qoG3{lHvKSLQmmU@eKAznzCXWx|1<+9xknzoioZ%8h)pjk$>L|bFN_ts@5*=I zHO;`uTZgXVkCFpo)5}Y8xY$*C4miOa@T25_*et??*lzU^rlCi7za?J9A0-FGrh7{6 z;beOzHy1YctD?Iz*!p{Eu&i-izPI8Y%ui~$g1!B|Q zk}P(1nf@ej@)o(uz>ksxV$<~{Iq)VLf$=6tV6&U7oa`uU=*@5F#nM-5rLUY-`VzM1 nLPufkj{MpkR~a}_l0t0y_L7P~`%^2TH_P`daGC!R^o;)liA7gQ literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_projects.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_projects.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0248ec15aecc2e89902c06cfbaf3dcf38279fd64 GIT binary patch literal 43284 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_streaming.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_streaming.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..572127898cb885c6511d1aced81e3d3e6a2807df GIT binary patch literal 43909 zcmeHwdvqJuncoaBcn?5;g!q1dPZ5#`k`nczM7=Caq$F#d*xAHN90tM+DNrQ9%z&1N z0FGm4w@RZn%0EebqMWt8yIrc;?NLv==csL*W_P<5wN6{`m| z#szEhBs=9kbgVsJVKbHOOsq8)~T8Pz@eK4YeDpp~6r@-G*xL8fvItUkzg>&Sxmy zFl!1{&P3sIIWuo${(Rvp3okExJ&?Jc`8SzA&%C+ta^}s<4}t$-fb4lGb0hNu_e}<#AqUZo{vdvf)^8`(?WcLk0c^+Z)-mXaIe7s`+sDn zsFM_pcRih=zDs@2lr}l21g++rFqTphQzp*zr{?b~dj_aiXpZ?3)blO#x6QwAu~2Dq z$~>VyQ))%9R`2FlC?!9mvR7SEq^6Dhc~Z4cRI6Pwh! z5;ZC}QLA$LTD=zPQPOU%BIQoeoEPhXwT~QyNx4!iS9#USRb4fuJ)A$~<~EVE>Z=S% ztKe!Nt(L3f>Z3H*z%_DBuyj%<939QV;Fe(&O#e;eSy9}$#wsk`2rp6NOnI9Pcyy}W+I6hfe*yT0_0&=-V)K+cz}Na z9)9tIrVHX=Z|`J05}rI47ZZbf`uqBNFNg=pTVd#z_#wPTOSVLGijU7EPD@N;cAEdd z7M@9*3&qc0qdzbW4PB#?m7U$wv;9FaFf#p^SH+@zuz#A%wg@-6Qd9$&$d^8$Z%ROG`#b6X)Ww z0KRqc0B7-fO$*^jA{yaE$tLjP%w$4i`qh-}*R1k!9a!^dEIh?aG8-1gMae}{<-&$G zdGp!?UxeiDkpN)E|8(9!O)8{6d# zSui_|8@u(3C|_)sED7>DkzDe5%NtH|mNM_abCOw_7ptJjft9}x`g?_VJkhH>(PD1` z5KYh7cv#?o6%&{MS6IFRa6^WsXQejzq7x*eTpDs1QJ0|bSvVYi<;7nCHu#^^uU?{Z zR?59)ZtGlA#<}~YCvwbb+H#nFx2h>y)wxjB`C8=q_Qk4Q_o)-KV@HnA8^POcTmK~V zdgz_D?b*idm!5jJw($>~uR32FdZ)Jk(#SH~pIy&#yC(2j^+HYeT*E@m&P&5fb#1Th zTBzGH2W0!D6Zf1{UE|fGe{kZ}6W=}cPHphgNX|~x_1tdVveXuuODwbvEVXQ%8(C=C zwbatS6zE=R+jhrotE;?YaW;4^oy;{VnVX(eb~-G$(@u8QQgz#~rq-UNwqR!KiA?Ls zrH-EKjvIS3y-#M^kLA}_@40jW>)VP&IzJC`>+E0Z+?EMFmFYOWw7Kv4-W$=(_Tfy| z$WmwU4jiXGXSTIg=e$&F5Svx!#v^;{JeNl9brG2_vb)}8gL7NSx_hpEksWxK-8|=5 zWVhe{lnEs+%l!_K``_Pg-2V4D3nu?cPB-rT@ZM)k6uapzMJ}J+^9|S6T$$>9HxA#} zk?|kQ+7B(*4}F+3s6nwbYsRK*~+G8Tg0>o}qCFYylcm@6W-_`fVx&Fq;51=w7Bx zDLQ2$@D%_vz*ntYG0@FH9)Pb5mNCFry^I2A;aJ))MXT__P8Vunq3Vem`Cje$f zfv*-Fpc`r`2GuR<5!E%oS35vg8ve~`6X!_Na0b*1q>~`NYN)M9M}u>cb9P~g^`A4x z8lZNO4uJ}RU!cTIhISO|22kqR2*#YnB&p?$(HzUU6=_;I55Q}yB2C+xrKxo*)Z?qt zgf14vImn%4=e$Iw_I1nDz5$s!N+wggdQPj#RLco6bv#a)a=x-8(=a+-rn+P&+B(P6L zekn)7ukxw_EbUA=#lPjMxtcY9)9W6NlMWkpkvtBT~R z$je32^jx#{S$nWPx%GF$lTpwufW8R3;dzQXS;3?0cVy)oDwzd0k_sd^wk6>GC!nBC z6Wtsr9Zk`gQL`A05$)j)&>mVy7avr4c-x~caYvqlai^?c+({G^0_uW^W>9EGPS@1! zl(itFRw&~95-Ki%3CJqUzM$BRDcghMHucCsXIgSb#b^wa9leYpFr|SB+npu7Kl+>Ghp127t2xqX$ zCsl%{CjJW(MZHA*9n+Ux&$7(aXE(YmGWEAz-YeB#8+z&JGSi&h=(5N(FEcIKjV_B! z%QDlL-RQE&G(K|irYy6;Wszxm#B={SQ*nj4^4SG%>mt+kF4_3pg12pvX_q(7EO-Np zOzSce%09Lw!-Q_zT$e^({_?x_sw+poQ}@l^S+sX76(s${-jOp?_L}$McBP8zSFXa1 zUe>Ra^(!#+`sm+O&{dW7E2Y<2mGvv7x;xAIm6A!atY0b78#Aw0@vy$^%6o02&=m)rLhXZe3dFpixK5!B`>7AtDG1MFo>3%UKoUk0L4rMYSzFM7oTeiXc-+*w z-oiL;P9TW^Dc)f|hP#m@G0W2*?+)`L_1l*>+Fyl^?qlh)YQ5B8mU&!Dh6z5_Uho1F z5=6MOig#_dfiA7wwJmpTO9gq$UE6Zk7G5^+OKGMRA0Z=*vgtsHI%HhAYg;NwmQ4pr zCCResK#3%|W_8O-^MLiJTgv7E@SQAC=leHj9#A$2A$aH4u5S6wU2&phO@r~ZY-T=# zLt3jFnb#A8>xPfFk-2K<^4od#plWMOZYgfQgOhiPYua$KVhJ>D+pwA*Bq1aZ)w#hu zLn};7i^UUQ5mb#L_3&A zEFQ=*8(bEdz(-dr*pp=**OFm+9;;RmY-8?QDGMkyyVtnq~L z>A%46SXu8_);pH{&tX*Gin-8iXq(|}52oV40BAbm1TZHbMOWZg-=Ia;-h0bqg_7=PeT znLhP#zT$NyGe^^>;x^FD5)K{G7$k%9&_Hn-AUV-~VMO(2O@HgT*!D;L57FV!Ri=KqXL&@4{+!BYCJ!Tw*4p;PI*- zGD?c8tyn^s6}Dr^9YBKY1E-)0=P}bHk|`wlGfV}vT!qqKz+DfTW-7#S?=%whr6+t5 ziGbuwKtyz+W0*@CkSCK4<9;M>V4lBJbnxq&Gx(9w!MA0Z4K6T;@{zUR8=MOI*iCEu zv&`dKGED#SW7E)s?*G@Iqz4CTH4WbxT&V7PpE6szE;*O&?b(eki}v zHD~<9RdYoGF&X(y1Qf=w;%ZYBMO-ykyjVe8HN(2VRdZdk?^$?puJFzb&Q8GDZGyj; zK;O;cG#`nMMI*%duz-Fd1sr)5&^7yM*`*5Ntp{RH8KI#XMLJyzfjD|W zKiWbo65=HVJt-3xEMlGu7Ev{}NVYyBJMhIfzSdOy2I$UveZn5BZ!eNTB>Rx;NAe)o zD8#{u@C0T#ii9{H8A0v@l9ND!UfJz!3KLEtc?!vCB)@~?fq+#<(|ApVPa$~@hzN6L z@~|r6R)SZh=9S)MiFaw`+6XU0$sb@9e+;^;qMl^~mZ6n?Wg8uXISR z?!CVkq;wxebq9&!4Hh_X2Aj9%!+ZM`N3cWhs(xU1-=MFH8UMblegA@eKk)-Qq`HAU zVFNd?BX!^gc6dLyfdy{PKf-_;*x`fLH;;#ao!{z$)cIZXh>4ls%^<&zhGO&kY2**m zkmr^O`CE*2q}q1NNsqYMTP_>&m007gO_={yHR0AGAQq-!Nw2 z^B@yG$*;iw``1CQ`Ei6yoE6_FX{#E4fwPfqo36=9;5<$8D+Y$Xuk2IxRcSkC2WOs6 zh#BBQN2wSyfb(26a}}JI^8sJURVgVj;hU=lz6Rq5Ts2{?aWm*@eM*&-?hx!K^*k7| zeGWAAX(#IRQudUKYrvMmx=~p-ku^kt;4nTx3&b^G4UW=SwS_cwc{4+C0T=;lO*xKI z-*81t<5a}-JdDevT@R`kK3r{AnQxjd`x(d!`S3XH5ZM5a@Z-8tK5!Vk8O!K8O0HXvqcDMA zg@T`Yoxmw>ouU3fK{nV-tkB@Y6m-2sZEGn3y2$Q_m(eA#N`(D)Nu)$NIGDyOfxZ7F{dK;wVWPXG<93jj?|vZpAZxe!g93rs;w zgD6BC2oU2^bkZ-bKr=*{q(c>yrmz`Ea+4A=AwN`tfTshIB;t{NLbnIaL?x!H!6XO7 zk>EzhAf$j~M~Xhxl3s!T;!ITFr!WdeC~+}Cq6;KoE7>4>*&iC(9qJ49InN~$(-((^ z`a;_WLVZ#Nf02*OB=}K?EHDLu60jIyl3ux3pr_{rUJxOOK$2BbNIe3)X{ew{vO*+@ z>6wJ&;4elKPz%Qk#Gn@SczUA+yecvtL#<+RbCGxrM4CNff}hqxH-v;fEVmO0Y7l=( z6npr#4bsUzw3`J(S=rGYFe@G1fqy$Sg2!%jq6fkXGQv5mE{X)+XT;y$?xZoG$6ndr z-ri*UQ^pG{qT4_4;7@{F=&-Qn!LaZM9z#QaAfng|xW5C>g2Zl@C=P{xj(K$acpf(= zkq|}ZLF5i0IgF$LI&RV{-NLVuqOKk<%7$hqPtW6;i(7!)6-QT1Vh08Y?K;Z>;R=kPBc zf*Y+U6n%oW99)S-4?6bcC?Km~(T7JYc()K+I;p1MQd38!Ybeur01@S`>$@^r1~bk3 z?${V#Wsaqqx|fA{3_%ovxvtkgr>-_{og04r#ifpIOI>{s zBB7}oLL@X-UUA;5rhGkfXMW<{ldFRoasf5M!Refb+Pw4sxiN+TOSQD;92C@8{rah; zj&8E*+A_E2^%&IuJ_b9u`@moa9hAG91T|P@2eXf1S!4(Af0>5sb{{X^9VCd3KfL>S z8rvShT>z5vGJJUNh#D^8poWt7-e|p1mGKW{?FSa@2M9_&_z&VA!kzRoz*<1b!`+CI zt$>n&oAVD>Axd^w-#iik_OF_1hFSKn29DCOadW2&vdmNTu#cIi8RRWA)G=?Rk#}I8 zdDeo|M^dV+!=1MII(oQ~ov*hc---vB?;xc*3E%A)f&BfoBOdcDhXrc8<*`7@ttvC{ zdB7Qa!vr`VDFbH^1wc;#&HzbCcrgIQ3;~!d09lAN071t*u&+Yji(r}r*g_au2{06M z8G@1N&>8#Ah7`2u>k8laK1{XBCVf2wWhOgBxm3B@jm-Cg4*i@)P8r}x;d>=LZGZKf%+@* zn<&zy;F?puB2d=%qK`mXSQkLqmSnIvls(5o;4YjVg$R}an^j>ffp}kowh8|fh*TAe z#{mBUc7p(n3Vsg0qAYwF)4l>E+43w3KyFb{m{+-0 zm}kItI;?v*j;mZ3j?>{9AW97RHHxvwp2kx_Pf#3(s{ z@I5Hr3o1eJFMt#UV>>MSR${SUM{kY-Qd%qqv7_XOPurK;wq|;cWLlqCYTul%-^-8l zbOz%*?ON&#W%{1XbR1jig4j<_trFb{08cP4niJFkVXe9rYJ+oI7HW514=>d2S!blI zpR)bSY$&_t<)>`u{wNIsypK}6gG5mo0taX)Z_kJK&eDp`aG!>Tpqba7$@uqX?Sl*U zL4t<%sXD{q7C=MC`vDCd_X8RNZq9$a5zx@_8ta?KtiawhyCC(Zhdy4<+^k@bucD#Y zO+Sr%4UKu~3EyZv9fRz#2J@svBaTx`<{CLCmW<6q?X%nMo4}0g#0t2IHj~t^YLmR1Em& z`aaHW&?^sriT}vez*^9fbdCgKJcdP$2a;|Q7c~h=OAP;-tb$?F zvy=Q3fIopBo0*)P4F!_kQxK4QiU)-kMBfgaoed1AGXiNoYdD_NMNP@7xaMA+a z=dzX$r9{SWjpMCKFkJ|FQRu6Zm>Lo_-!3R9Ij|w4c#6rc5yMH#{xXZ=vrTKpXT+gp z0W>R6yB)&TA4W2aq(JRv2voWjwVMmiV^q0RuVZ~gIKPV=L2aKzPB%X!in!+>?Ll!x zWj$K^O4w$&Kjh;HsP~va;P$fz0k@r&BSj*G_B)=Sa}?5&Vz;|iD+$`^rRo|kjohy5 zxJ!B5Lsu+I-n#F63gRzZaRAC{>PN*_YnP(ta#&E!WpAmx;=1Rje4)AXi{8Oy@BZu~ zEQ{X#xh6Pru0vIng_Z(s3WRi7F~+BZ#Q3b%)m}%k?8cXO*dzBNG|KZnk?I{n6)=a% zfaVJKet2(81$1zU)KTF5H$HLWiH!eH)_!=wewZM-BU&`jkpUbAwxWV7h{M2ERB-j9 zf@>QL12+IWU+aR@`F47^mznQikng6U*nE&iejAN>dI{fe9WmSHchkdz?ED@Z@&~a- z93&o~ZL=jC_9`w`eECLeqrlRXPiyd8a@d%KkmTOR;pPOssno@2>Gv?6;g$?JgX=2ni z!xi$V8ZmXP40^iKtR~lHO|I4r$kl3qat}W<4b*bYC6lXWvk6e{;YZhUf?S(_Eplzq z7lXUc z!H$QREEdsDMmZ6b%*sWM%nJV!YWNcbxx)_{Dn3wbq5NHWB|BtsdMvEci;7n%;w$2Dj>te z!5vE-Dqwb55imQTj>GhpYR{!(xid6XAAkuO?!)vI%d+=C_7RqMyayf;e81z}zwAAf zeT3yF-a|PXp3VI@jo0r!xr%oPRWLOs1EwzA`{BKdDyCj-$oLA}RWDmlp_23@DoIbG zk`%Z(|4CGmo&+W72`jK~n_ZClwue4h&%9m1AYVm8vA6v+@-;N(sV97+^<>EQb{l=N zlYP70hWr++@$DXxKScO`$0^7^SbM6*{NqXs)bryS3#9zG(F}Z^lY9+PZv(YYfng=> zpbYU%;ISzD0p2wIsabvZ4^Xebc&hR353UnU6Zni5{8XIw3Vc0Cen$RUq|JI{_X&GJ zA=pfrRn(MbI16XxY_bs~=K{qh`vwiv{RWyr!Wb^7%q%I391}KeEOUfGv!dwq+xv7P*V{!t&cOSa#7Hv6~%d60B25@w<{DEjMVbZ$wxPCS($v)3>hV=+g7hq0H9?PVm{g21 zb+22d#_{@xpOtnW0WL3wgSFshv8c?n^F1xOd8%A5B{cie7tcCNI=%zpaeR9OC=B>_`{>y z;H5kXZBSdq;OUA~h4{Bz7q@xM$ie8ust6iOXxdX16~Q!>hvJu&B>+imes~#OFA5V{ z){1$I?I_lAT{_UO$KR?gUr_>D-n~l8wTUy@gqTN1oF2H2ZmzX_#qp{cdJg~CuXtOL zE3S6U==tfNP6<~CoSXCiJk$N0XGeE@S@30pHk`(XTcD9DgcHxQ_~5_ zj#Khr`kW})!!TGd9^*OTpTU_0&5{+S%faY@WJ6O9WZW?C52lF4V9)?c1R2S8HXJz* z4!R^O%)L7ok4fh6bX2lKd&6hLA|JF%mMNHWH!0cSXfH%z@*U)qkL!xS$jB)A)Z$~3 zi%j!ZHvCiJi>N@4%m@M+Y~wyrVk6<{@Y(2OG!f-R$vPQ6%fmFRR%og0k%0_QRN$r; z37VP-IOr-nV|X69Q6Nwck?JVX`xg#o{5#CyQgYx~2>%`n{s76FNd5y5kc=6IY=l1p zUSiPPJ4h!hwI~@e)oBAv<@*tq!FJF&2CEK1L4G-ks4tQzw$$k$RT`3q&IfTbPjO08LeM+C+vcJR~1*0oI*%Z9kz{(FX zI~hUw1aib`=o#dmMWWje{YTvVA`$_~b3jBK&s|~vE!z>b;2DmF$7R!fd>}>@2`HEF zKSA054XXdcemxhO5^VioQMZxmBE!9m~#hjH+7)#P4mvB_8wfe^++8jq~>FH zd=9k5VH-V{PTg;&-21cazO^sQY%sggWswcuuJ3y7g@yXirIX8SZ+4^0BHR1O#rv}C z2A4&)Z<*ba-RQE&ZdqpcWH-7jvU~oHJ(XS0l3`CRv!BVXXUVXiS!NGqAKQ{)4?U(y zkMcyy3_G~Y9?w3uCBq(HZ~O0$(D3Bm$H(^$5@k>gI2cpQ+w;_qZVSJu}T$ zFz+!8@^%`Ez2~5jXKBduUIpQOR=(Z#UJcFHv+vc~kZ-{n-)kfJ+X>(07=!%1wPURL z7d8vj^9$AjDZlWUfe$uHw$V}e>W+>|_Omlln9m;*$s1d$8#6b{D^p8do z!e=3q>>-F)s2;~nVt)EEa<3x!9+E#qf@9tSzL~F4#9$_*4$)IkqjkHCk3 z9{Le?7gtIZz~xTU1(}c2^uZg?T!+r<60?zZ0YM?={RJBZ`X>DZe zlh`q{hIJB?jh&Eel7(?{2G1lX%sE+yox^5`vzcvKHqG|ziM%^=&TRIdZR0s7V{*>y z{=QpJwYt^SQe(%KiqfrH&Aqp7eN}b8?|$F!`|hvG%Dfyl`MDp*e$dKs|3D|&=29~s zcs(5V3MX>MIFT1^o%}HyJKDR14ja#PIgUAb<$qkqTN;JoUBEb=ZZz^*P86c<({r>l<9Na*I-V0=SARGboO+9*Gg=|Kq7^5cqTAGR zWeHlQRt3>xYPD+Wt%3qxQ;XG8Z;>c~e)>$UhDz3|-_&YNX<8M_*wZREwOl*(mOf92vs(iJxOs$4X)2jM*)tOpuDnZN2XjpG*bzaF@Z7{Vu zzXYu+v~4uCx}XHDDioM)YPGp!tyUCvnKQnx8_{H$>Q1V*xjpUok$ur^PPjUX^&fd&!_)xI&J90{+>1-Y$^V2JKcy6Pe}d!n;g?YA z&!|-L=d1*^{g?Rt8MN>-T$5V3?)@#vLkAG=Kx2>Zy7VVCA@Tw3+2a@)l#oq^O1diLs@sRiu+-Y+ODuqqtUsr#9mJ@A%YUjAG z+g`DM$>HDz`2>I3rj#!B8 z)^Zx123_%5?YD$0K1<8Lu0BJ0QWtpHCwfJnWy;r?dYpsu@f!Uq>RTk4YviEn_!@W; zKGAQvN9HG-qelssM!SOVf?g6m@QY=Zdtko996c~m-YQLuXQkFakIGDx&q2q8pxvif z{;I&gU%&pbcj`W@l3jMYRxeNUHfBljDEFTFB%sGrvCVVm)kH@-ChC5?8={XT@ z@9Yf6+vPLzJ?9E`JowvNcnPlZ_iURtU*!iLXkOIYzd9m&|5VL{H-{;=G9oX>pcU_v z+d6v0sCO{DS^1B(5jpLaqH=Ffw;a7{Puo$JlGT)~y;rUbdaR8HQ4~KucyF!g$2Hou zkTI^ch_1ORA}?hX(VvKSFzt=Wv2JvtyCa%*u+FCiy05E(L?ouRS4jaix8`bD+K#(V z`%K+Vd$;xW$3d62b}3!a)&Xj?Nzq=Zqo=F4U5cjt`0uvkeX&l^OinvHdr(47&uWuT z_4IX$tZ3WG&Yt7#oo(IiUD33sO>RFCr54iV+u2Yb>*?MpNj*}!s_jIN)YTqui+-WE zv%R}Lj{ivur{$h*sTy6B=+ldMBpSmcdJWS)y0$`?Rpyg5=934M6Q${O=9Bdj4KC(i z*2*+MhqdkAvK3NKPkcqM)N?x85tmoQqwQVGj`y@nBC<030fb?6UEbTDu3~fo1(l!L zh|&Hc*|mvM46O|P_FY`gr#NN9N~uzYCusIV|qqWwL8^-Bj{IPl`3 zp`D|`ywpdvCxv+#4;NhVdpU^vJQvzMi+_dR9TG0>+JbD_&q$*ZGVNh!M7Iv`Uwv~U z$9VW1@RW&pcmm&V9g2UGdrjwB;OF)+{M??f=j7*tC=5ChLc+oLxijG`$j==KC-}LO z>X`Vs%kl_XXbjgmYQZsFFNqP;Wp%_XP|l%^ilSTP=PuDBdJ`^{pHFR^wG%JLpA$5< zPsqx3LgS#&PF@ zGS0Xf=uvrrw@#P+#DL1ry_Wg8SEG@31V6VAIuo8&?#bsNkNOfmF-YTsGD-~kwebRF z{0S%f?#mKoa;K?}x#W09q72`C1=TUWdsCfUa*AK;Q-N~E{a3zfWAcnwtP-meUiGUh zw@i(4?TXrwsDUrekH4}(=lQP)98u<_K!@!l*I|1SbEUyRBB0RbqV2p#;0A;E#imos zp9nt94HAXEVY-W46bdm4)qq05;wdE7T7G8>eKkSt-V1&;dP%&awc;$5_7!5CSf8j+ zY45U3dtIj1uCLL4u&mz>>C%3FCs`RfY=eQb9FsE}2Id|nIr9W$rl>@cCKDjS$NR%7 zf4o4Qk4}oi4=n5RhvW~u|yxu7!h=xB7&~F%A*V2=E|{dCTc!F;w;?)x~Vlog5?8a znt{+Okz^o|`6N=QNO8;QhZU5tJ6TA%7D^UT5}{-CLT2%9rTJvFS(tk; z&BvvOs1lPC6#;OKOhnQIJmV-WrGj-?0^ITbHkD}7PW(SnRel*Wma$@7vSq{dr8nZk z8}}zS97r}F90?st1rB{0ggQv76*YG`-to}$!l-*=s+j$@dt=6qni)4YujO{2<|0Cx z2bzVq9(;HyPJZ3&K-yte@aRgTZoKv-5IAiKTTX$~4uMlh*b@R{JdT9Jg23ru0w?Mi z!zu*6QYnyhp}F92sh6}MaO%RTiD~3wk;YVB6F8kr;B=O3_$Bt|0L^U^3Y_kQTV;73 zT|jUopvYp+$(!(65D>h0PClw*Jf{M8uZwL3${Aw0Uu9B067v#1l}digOiC9?vY3G| zi744HL&*|RvT=rzC8Ff)8A_Ijl5=J#St3f#ouOojC>friWQi!*G(*V}QF7i4B}+uf z`7@L(5hWMQP_jgnY@VTHi72^nhLWYBBr}Nm&wq`XL0bmG%nYi*?d0BQN9;tbgIPn# z7`mtRo=dTVu8>wkJ8jAOxdsKLwUn%*yD${kXym%+-M3dBu%fJ&{W zWFsY8DcMBH7D~uqnRa&fbsdjNY5SRIKkSeFz0tJ0SBmvWv3NfKZ|%pU0KB;YLyMj4 zj^h8M=+m+2SyXV#eaD$;H|>z)(Ox<2JKG-bI2D!1P@49N(cWmcD7OLR=I-s0W2|4^ z4k_9mkBV*W@wB(ES5)&?eNqE;i2Tf?*_4oHnKYM@FeOct%%g$>wb%q3x-__R^Y4eN``Qeqr;ATZdXlg+-}i_P2#aqr&o3G5g!X@=;-9 zs+j$4VdJQ6&g~L-QN)!Mul0a$?oq6vqpuw)MWQ} zgt}1y_ddz}9bxvA?|qV&MxWW$Ki=x~DPfZPJ3{@a5Kc{Ye@6(93UgDF-QN-BPWib{ z@#>SW{;n`Lvx*Bo%&UOd25Bvj3DRoeTT%$w+`cQ704FFggt&@}=eW3zkUg#Xkzioe zH_aD9Gwi~Lg*#2KhOAA5Z%Y}Ak;^Da*qp2_p_hb_%UPhT0bQ85O#`~1 z`M$vG8on9f6BKygzG!X zksSjkB5)3M_Jc4`#maX~aRfxbe?*7q6kR86Kv+8mZFxM?2!7!u<2!w+3EK~SnkN&G z?S6~M_Oc?8?d1&F9>_wr2TNv>*1xNQCTYDSzSdv?XpH_97AR*xXexAMd!<-aB4j(^ zYb_$%6CSae!798!*(y0_!mA>YfmSXEGy?1gqiTriScVwrDCq)JGLXm`5WvrX0Dct+ zP-YouRHlt(+7V+KQOS!xAK|mbI-LqiZhBIoUZ;YRo1Ro?(5ay0rY98|bt)*i=}Cpz zIu(@MG^B#r`$>@lv{96Y|Ash+QAmi>fo%yPuE!$MBn5DPhx{9Ht{Aq=!9N;rpl|^m zq;qt!sboDjsH4uN3i>gPs{vW0sX)&S8DpO6$22wX5hhjOIciVbE6xY!Bv}E{FnKl= zlob{*S)ut8FDoom#{_S#_*$R@FSf7}c;|CP+8QjkafxzmW;qzZnJ*F0zNx`rA}GHq zE-LVJt;$e7t=$6c>2FJca=K4ffpYr&6ey>AJ&BQ5oy^;;!m?CXq1~HyG)r}m>>T(j z$6sJDC@$9CTbjcFJnIpar^w!5z-g_#zf0KryYv(P{w|}pQyIxY33YC#e#`K1vP6qd zi`=i!Lit{a7D{d!a(R3a%aw6W^egW|pS3q(1^X&ie&WB1Rnz+JS1WgalAEUEzys6# z{?|jr9=R-ak?$ql?adX{PP2g(j2$NujyyqyQ?C=IK? zfbi3?_VD&c_k>Ap&hQFp*YWm_Go3vr)559P$y3THkOgUHpL7xyf{0Vv4LL;GL&*V3 z_EK_?l0%fNqJ$6@S9yjRIQZLN(l4aV&hKWX@gb{8MPduH7Qk* zQGdd%E${2?jY_D68=>8)INBSXCnFa*%x_2w2*0tsQgAD}pT6p9PxrzQE03ZI zbI6yknoG(09e)M|q|Z|F7$t`(If5h-#6^1W<`j)2k;o3I1Qs}rT-s|;B<*0gX!hzy zh#!gw0Q^e(#1x{`66tq3Q)z(RW{wJU4185bEmT5PIdjAs>VyFl9es*m$@pOLwhon z2;P(mH4lfHlZ!T9-*ihDe&|4Q9U$x!o1=&4lTsgjzY-ZMj5J{o+=(v(od z{o#Uyk**QjvN+9(o9PzH@3U?ppt_m+#URDhDv5QO=wn!y zE7kk2nx^-!VhqI4G`;^C_4(IM$NTShVs>de{~I>DoHej)?Cg@{@i5I_!d-bQ1r0P&$RQz_c*ejF8Pa8M>TJJUgfrnti67 zT~RUC*9CBUoPH~MN}_?=ms9PtluQUd%RAM`3vQp4Jz;25X(tWgYW?_=pMYsmL%3Jg zU0R+DMuzsh7YcuzQ-tE=+tFf#C5TnU2JRXaT2jUANuebZFv&kn`vv|#@cVh;d;8`f zs|JtlLzMV2!DDq%Xgz!mG*L(kX(-xXwIQ5%?tt(DL?S(yY{LG-{2~zoisy{)z>i55z3I@3ZCn1~ii zZW_`;6&re)*bq{^@^q!NAvS0pZUbf86<;?o%%lXSIkqt!Gh4lbxxuvo3C{G&mL&&L zX-c`ohc5g_TA<+Z(if491Wd6*pP@=q;r!&3pyAQgqXJ8xGmiC>>_pM#+5JghLdECt zCqJkQ0E*@?e7XAK7vFc-nX=IPF)Is;Q?R{D3bysBV)l21^~F}XEv(O!nfQD2-g^E& z@_TEAD|=QVo7*$yH=we{)I}Tg8|3GL-?BFm`i&n7CY^?3j7}qefKKN)FBgar%9eA5 z7Z>w#ac6nCcuJL;my1L9aw!{Fl4r)!(-+r5BV$|%pJT1l_z3-*oqg7dHG;4e}uS;n-Ibt4jV6~!Vr6O8bRdz^x>iyVGrc0#fFKPc6T7Y80Lz$G5)tvulcWOo zZ56L+Oi@C*RTeMd8({lf+&ktLnVLCp4)!PR_z^CW?ulo6r z59G;WEfb%XZ-8+(h5}Y{)9_8`_p$Xb{(((28`ag;Sd0^G5sYxGTLE%l<*$LtBTD!a zHp>hjrWh^*RoZXnST4R;H;GOsv80+TiC~y%ml`<*;aY%_ia1OFs4VQ$E)-|jCsoA_ z;iZ7QsI--9qRIePW807T(vDsV`Z9UtHbrhp`;;4+3Z5pfl3PK=s28B=zH|YX{4W0F zUzz8&3-8n}y;C_?g~XglRyM!yb5=Qr_GN->mb-8`wD3ypwYuwT-k6(QxjotP@U4bq zXyHicNGfooq;lyPJWO$sR7M5B9E#XSiv){|3eBlv_F@4o!@|m9quPuJi!xPQ@Ti6- zXn8ciU*#Y53x9rSKC-!$=!<}dQs&!v=GllRp()Kp`)lxU*Ho)9egXx0e(8M%Q-BXn z+Y*NKt|`!6Oo3h+>Ag5ifmwl0-s{X%QluoK;0prxS(4Wj=zccSxgYofg%;|3MpvL0 z$ICwzv|vhO{`~)8yu5m#foX0O<*3fg<2_-r(y@RfjK?CLn^Hu24sW`3jys`hSWya_ z!<;=sjhEOGtT0KX_X>3MWnAU2@h4+-FI_#OK|`;&u6eF6cq5QpvF(N{xp4O#cj!Vt zDTXt4+oQY_>Y5KYTaD{?oGs`Y+D(`>*hovW1n3y%c;EHEq|6QACe$cL^LJPB-{*G+ zgdgl$jBFka(bS_H5xR}Ed|4YsIm+fr1^E$)d|BI|0E2G!3ZX_^AbPj5HZdQ@tyEEu zbR7}2Nk&hqv$}!PgkF*vbSEyW>v;>bS(qPLje4XnJd$P5RkcYpuBcIu$_Liu8CAx_ zKcXQ9P?oXrj}(#q-+>(Fq2`b2d_uQ|NSX8`75WoO+9)X+nWC*w%;a`Ii;6^DB!k$5 zW762AhZQ4K6H^tNMjeDl>eeDZwXwnV>*(Y!@F!nJqD4unuKjBKm4S-`FDFK-77Xo2 zlq3a?l51E>-2Wko^vEs@68B`X8l2nQ8sLAZ zE|<>P`1<|2E}0f0D~&YjDngf>+vXR5Pp$gI#o%lD_yK z_)|>V*PaTrmsHz#$MAl`dbBm_<)LY;z9zm=f6ae=Csd6ip&hBfj*?#Yy{d*+HecNQ z^46jKqr&P`G5bFWD^kVmnOZJ*oc}Q6<$|qH)6K^tI5%(KR{neZz74|ncRYyf%|?FT zCgJAnU9)lWt4%!8+>(-aMp6R#@C}${s$5cyE+=4~G)*NX(vx9+JQ)eOYDQvWuNh1J zpd=<0(#m{*NMhhDNeneiVyL~(62mMV;#?=z-xqmFS+drL1zaa1FE)I#?_ZUe{<}$H zY8;qnl9;6E>1aosgr-~~6BM?eWd$TA*Zg}LMJFpNxmGCGN;|Qjg$#3U%i*4$a7Ry9 zZ)fxiVG_=|`okT4l0<@BM|*Gk@mMF8z=+CBx=P!6&K#CLPaTjc8M~r~B5_SxauS%o z(>7|?s>D>s6fw#jX&7Kpk-W%I`cL?iFJ#F~bN`TKrd`RF);sQCvhpFQfA*6`df`W- zkuG8>gxj=m!y~C;_N4GgrcRZmm{wX5rS{uNl-dkY>LFxrhWY)RaIhzq=4%1(*hf%mB2eqcaZD(z=fK53ZaH&wS(^CWNJGX6%93PwIc#L5_LTI^WuKps%Mbh6q42+*B3F+6smZ41q7R|E@B1qLcwMcIA7Vco)rw) zI<5xZN6VtWSY!FiDb#a=I^YAe73}%A8t8dVfp@_8LTc4uvr}7JoP866LchRNEk~J> zWtL{n6-AO1)J3bpkPa9+NQX?_bFN_jXK7)s%b>^v9Q`m6EJg{Lv?O|{(itQ$$P{gX zVL_~ncCMCLUIOboblGi`WC;dk_-%}vkYb++DfSsr5e3>*N=ZLJ>Hmj6Ii!mOP+X`{ zgUmJ;+@MOX=aZX>8+CF~(qQ5fmn+P~ty0H26DLWAZ3%mhi17v! zxBC9onVwsY_s=9K7_4wYs*JC}nD~Ts44ea)W~q8UF8({1_=LSM(Nn!w#>6MAWBBlh z1{1gX)OFjFHcqLc8&>I3nfTL;m48lU<#XBWUy641JZ)y?C#0UPEOst^5AVo*<>lp! zm#3X#v@;ry77vLwc)Ij`^i|Hom6fO+?3PsnhsKz%qLvrWN#3e6W;f@nNqOssz4ezK zzx=r?@$a-H7p%Q@G}*9e#Jf2qY)%TB$?5>+-7*|%xzchia=rD970Fc(-&mMjv}Yu= zHx<}BZJ2ij=h*Tg$PwHJ79IvM&o#nh_gnnlYT?^+k$aQecz?d92HCf2d2;3j)JxxJ zsC^cH9~=hUKmiw@KNjlYm_U*3 zOgM8!?r@22FbY?~&1T@9gs0#P9Ay~P19glU9t;SCD`MMeua+~88dGA-+W=eS0Fov2 z54_+ETrY_sr;q_dk1|nUexbv&M9uW?6$$F$Rp*#K%X3UU{)K6vBGWegc|db}t{FI< z(gZABYl$a=aE(9$ozB_}S_6u%1AxT91aOf2??iCsoiON`_~_n=37 zo~bRlL0^ut%!cRua{{L-`+JzO{}f~QwW?FqiC8E2EAe}pvCFiYwzA%JM36f0WTkIV z@*JKw8qyma)^ZUDsZ)HK3?5b{3n z$+)@t=2Tr|xGs`hy5)M}R_pNA&m^~eHo5q*JsPIo;PBx`PJxhkW>mvB<@5RBoYL~Eh4&{uKXz=x#)7gSgA@R&fvL`5J(u?z+U@gYcn z+#BtPorrZPeq#)vNfXB6B8d%D0i_A4XRCGrxL#~lFq@B3V{0fG3n+;ACu?P30!VPO z5y_aav5oq(osuP#Fz6;}7_;RJMar0+Q_9e8ReA#zSCgFaq$XzoR9LzAPVL7XmMja! zsfrLAuWz_<_^-DnH||R|?;i;rNCghuXYpYJi4W^j#q77;>q&grKwi}CiuiC)6CaxQ z1}J!$N4T_*#Xbr4y^U2z9FIbxGY24{;T4U~g7M5Vaya6kf! z^f_wK>@)pi`qjeBcn6jH2@+*_9wl1K1B`R!4KSiij1{sN6JwPeKSf)+iDCU;WV9%E z!MaPK;lSLX-3X*jbIr^B*JfSwCYv7`32jORHr+QCF@MqRVB}K!g^CMnU!KJxc_SGW z!l@GNw}mhnz&HOMlmCjvA1Xlq*}IzgZ}Yom3zv6JxW z2Q1{vJ~c>(%0zO7#MA~JMN0jOFOM1A{(>)y&5Xyese=oil)lZFzjacuj4{*Ai(#{B z>eFR+lf+$w`8!TuI@_N25rcRRsJ2!e`0E%aE;J|KCQ+S0SLMt6a)6erck{k z2JC=3C-KIuQb-T>r`^pto*6;;^NRU1I50c!kWBHU6qhivQAvM=#L8GaCOj>D4-eq) z0};vBcr9SglPzHC{0p{;zD9_B96g|k$~?_|oqvVrx$_ast$4;{6fd@e5kBqc>^U2i zBKEXx`ATISK%X|mMx`zpg9By|Lu}G;&$9`RaM3UHN?Qh&vH62mlIBUIrHm6bmT&6p z>1gkiw=CC-x6#PF6ixjL8=Bt^9J*0`!Iug)4F{V>g7YuOuk8BHV5()~aLdM#mQC*j zHw_04eJB%Io?W(*AH#dVw-{GSJ5KcUb&HX*v_tL%_HP_ctOkmltmX^2QYt&V{tOFFI`op@VV}0At?M`kfi+iLsw_H` zRWgUNs{8CvrP*1_n4PsW4*15J8O}Ti1K5tI^sd*=yzAhb>Sl7Dyw4*W(@)#aKg0lX zyAWt-gvnI11}d49$Y5#Oyq$@LYA699-&+;P+9bW<6z%h6-0%btc#2Lm8H>1L3BQ8c ziaFf8K86K@_g1R-9ZI%QvYiq`5uG=NfD#Z*LGme%a5qttq+|IfNR*j8R>Y`F)-O&5 zmkjOEgAjdW%jRaQoOKA_f@|?N7GG;jF1jzG*yO?Y3~0#o9cQn^ueDwsxYBulbRHCt zsX>pV!f>cbA<2hScmM*dj3F@aI<0OTAaKUeOS zz+Xd;plA`Qm=*y&XG{`sF!WmbTNE51R(Tg0P3f(`sxF_s(to}B>KBvCw_blb+5E^z zXjdw*>wamxM+K~v{Soa+VP3|=1$V0ohjWd)=K?yuyHWV|t}V#s<{DhRiFGC8pW`Hig8Xw_4e(Dh zm(Haw)K!3DeaU)0j*l1Y`M4VBdA&(W%++&)^EarRe`+Bb*qAWrh!d=eZJMG*Qz-Hp z?EN2^WYDO(s_V;Dc1O=L%scSz(OzW^AK1Zy&cvhax9}=MT#$Z&Ygi8fm;*Q_k;MCm zDNxN(YB5F$IkZZrDLI1##`ZYni(cc})DD?+%trgPhi=lsnjmR`QDYl*AZvb457iJ^ zD5r=mif6k5p*JKK=~pQJ90@M%`W)ZM=J-pJ!KJ1(>9U>NZ^hUObx))MPn1@aF;BLa zrGz5(qRjd*+>Z)NQ}=C83QPZo5V#;*IDFCjuFyd1Uw=-MljiOBA$rYzxA6Ua3y{q% zCxu~VE!HqIBPSIY-Sly}KsjR+{Fvn=F(3xT3M_T*h2om#GFcm~j;blHu@<1A#1<0X zNy*DIQ24*S_%S_zp1lNc(w_8GAaI zwAe6E%k)Sra#ahc7++X<}fgsyD?TUff4KA(ErFQ z6uVG9xzZ3TC-0x|Z*k3t>Yu<+GQXrwObqmVi;BF2#E3DSSANXzsGvxYre)=r?o0Y_ zX!m83ApeUlLFTj)eN+;p*+_(rEW8t}hTA1u)=pWxJ!@IJ8@0EpZ;8LGzvWN197+E1 zNfz_^R4Q<)v~sOE_H=P8vX2T_O33PdTWA>-mZXZ=M}#GrCK69SgexcwCLc%-=nSHH=R(kh~8MsO{_kUw#Ps|9hD7WrUo4Jc*( z{(vvCy2MAYNX*5|k}epjX*0cRxeSH6=}J@4z%P)gaV$B*5WXlYWV1zlQPrnmfe$r? z!!hPi@|223QG}E+Xp~$+ih{kEtdJu88jscrE@kqv2SHLIDjKCI+2cFZwQZDar-W>4 z1y;0BgbfoKMG-cJQIlRv`M@8Zu#r2cRLiXex8lECd<)=|qa&fmQ-Q}zD_5BHWkrNy zP6@Ng`;nMF)~I+wXZm{uF#SDS5LkH=vNxOfy}WR9-p<)Ld5hsoyb(Uxvt|!*m-)FF0AumV($T9C{nksyp*_&BYg(vTwdGbV|oM7`$+LI^k z6cgk~TM1pIAHrX*H=x20_{2(B^!+pMz^CRNfG$oAF|I?Yp`$8S^^{h^c)3{^F9fnE zLQiQmjMsQQWsvD9OB2S6;N?mfFDIy=tnXH?eG}7^3Uy3h3FO-}z5jaU{_z$xu-x?g z78+G5c&0BEObC}Ug-dGFY9JpdypH4iS){`GL8SKc|AtK&XAdl7dTkvAAx0@gUbMTT zKYTnIKO2p9D}ewNK!oqHo<6A~8a@o~&q=KBGvGViE&-yh#(+ zzX4)e^QEY&zqC175gFP$fhz^pU~C=L!omD9ggfdzTA_nl8w}oYs zs4@n+vPxgdl2){YiQovd1?%uWpKCfgAC4tQ>)}|k30W+|baXbBVQS@Za%&!cG%Vbj ze_%aMZqKHlZsY&w=TRX1GrkSgu|$o0A=(Nq3eH08e3P589u!a!6hEQ7&#)KLlAixH zV|9UnrHs|ZPqoJZ{_kcB05g98m~CTmIof$5EJfpeQnwslvvMW#3Xq6LNh6dnPl4N% zJBj3A>;m2+iJUDYXrxrTC`FZ*Dk!0WTSBBiF5)qH1N?@5ozQ|RQ;Yg&uMG5ynIC`| zP)EIQqYk%8(GyXugVzylJJBBNl%@ZL>c64U|7*x-qkqlZOYw_s`9?d>^#yMPk}I~| zpe1GQ7ut^8#_Xg8yeE_0_uRrA>-j7Ej&;J-9p%VImZV*6ZDLPHTU*+Fybm#mW8Jbe z7bVkmZN2?OfaOXsqF7J&aw&RNip8U8kN#8IrT!+(raDfgO4@NM9`F5v^e6P2yQ8Q3 z1T`cPJC`WXL|W)-m(HYJ?Q(y2N3199+fJDsoiQvdC@o_J#8^jM+DR3TQPNIHf|4Oh zzC_6jlw6?XWlFwI39W`KeV39SP;!-$zo3MI>`4^3h{b`B$nlZ6bTD`%vv5nKd6wRz zgdXNq?gOM8dlT-tH+A)heVx%Q();)gkC~JA!3dhM^F05)&BhDAb8H7rr{-fzy44`(=>BtLspId+`9OXq4;84d?@?=BtH zn#g5lWz}?|TcOm3y~7*!X7K1+SQQ-AD#(p{1*-U!2HkMN=!T=-zm?YR&Tb>&EE*_PpDTQVF@ZmqdX2ek@v5D3P;20nh3 z(Knpj0_kv2Yalo7k%ssc*Vd*UXdQl_HN)W~dH9LDbX2P#msy#0kxIM;M^bAa8D9HH zhQmqn=rQGZ^2}X2SF0kIS)5hX!#5dM!wI7uj%pR;#yue~Uty3AC&@<--^H>c6>1ga zGWA(kso-0T*8wN=G2o!qKyKUvGK~QA8qB8}6AuJFzR`F#IH6~QgL)0*#!LpF>2t1YT%&OKrXW&>&_d^y}}7Se;m{r$Yr)<)o}AIH=0uqKQ{dE XV;K%7$)`{SM^@#18*)+xgX;eW63L8% literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_websocket.cpython-312-pytest-8.2.2.pyc b/tests/__pycache__/test_websocket.cpython-312-pytest-8.2.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1f405a7cc62415435ba121acd5386f50a2d9f8e3 GIT binary patch literal 36556 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 G^Zx str: + """Получает JWT токен администратора""" + async with httpx.AsyncClient() as client: + response = await client.post(f"{base_url}/auth/login", json={ + "login": "admin", + "password": "teamboard" + }) + assert response.status_code == 200 + data = response.json() + return data["token"] + + +@pytest.fixture +def agent_token(): + """Bearer токен для агента из документации""" + return "tb-coder-dev-token" + + +@pytest.fixture +def http_client(base_url: str, admin_token: str): + """HTTP клиент с авторизацией для админа""" + headers = {"Authorization": f"Bearer {admin_token}"} + return httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) + + +@pytest.fixture +def agent_client(base_url: str, agent_token: str): + """HTTP клиент с авторизацией для агента""" + headers = {"Authorization": f"Bearer {agent_token}"} + return httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) + + +@pytest_asyncio.fixture +async def test_project(http_client: httpx.AsyncClient) -> Dict[str, Any]: + """Создаёт тестовый проект и удаляет его после теста""" + project_slug = f"test-project-{uuid.uuid4().hex[:8]}" + project_data = { + "name": f"Test Project {project_slug}", + "slug": project_slug, + "description": "Test project for E2E tests", + "repo_urls": ["https://github.com/test/repo"] + } + + response = await http_client.post("/projects", json=project_data) + assert response.status_code == 201 + project = response.json() + + yield project + + # Cleanup - удаляем проект + await http_client.delete(f"/projects/{project['id']}") + + +@pytest_asyncio.fixture +async def test_user(http_client: httpx.AsyncClient) -> Dict[str, Any]: + """Создаёт тестового пользователя и удаляет его после теста""" + user_slug = f"test-user-{uuid.uuid4().hex[:8]}" + user_data = { + "name": f"Test User {user_slug}", + "slug": user_slug, + "type": "human", + "role": "member" + } + + response = await http_client.post("/members", json=user_data) + assert response.status_code == 201 + user = response.json() + + yield user + + # Cleanup - помечаем пользователя неактивным + await http_client.delete(f"/members/{user['id']}") + + +@pytest_asyncio.fixture +async def test_agent(http_client: httpx.AsyncClient) -> Dict[str, Any]: + """Создаёт тестового агента и удаляет его после теста""" + agent_slug = f"test-agent-{uuid.uuid4().hex[:8]}" + agent_data = { + "name": f"Test Agent {agent_slug}", + "slug": agent_slug, + "type": "agent", + "role": "member", + "agent_config": { + "capabilities": ["coding", "testing"], + "labels": ["backend", "python"], + "chat_listen": "mentions", + "task_listen": "assigned", + "prompt": "Test agent for E2E tests" + } + } + + response = await http_client.post("/members", json=agent_data) + assert response.status_code == 201 + agent = response.json() + + yield agent + + # Cleanup + await http_client.delete(f"/members/{agent['id']}") + + +@pytest_asyncio.fixture +async def test_task(http_client: httpx.AsyncClient, test_project: Dict[str, Any]) -> Dict[str, Any]: + """Создаёт тестовую задачу в проекте""" + task_data = { + "title": "Test Task", + "description": "Test task for E2E tests", + "type": "task", + "status": "backlog", + "priority": "medium" + } + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + task = response.json() + + yield task + + # Cleanup - задача удалится вместе с проектом + + +@pytest_asyncio.fixture +async def test_label(http_client: httpx.AsyncClient) -> Dict[str, Any]: + """Создаёт тестовый лейбл""" + label_slug = f"test-label-{uuid.uuid4().hex[:8]}" + label_data = { + "name": label_slug, + "color": "#ff5733" + } + + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + label = response.json() + + yield label + + # Cleanup + await http_client.delete(f"/labels/{label['id']}") + + +def assert_uuid(value: str): + """Проверяет что строка является валидным UUID""" + try: + uuid.UUID(value) + except (ValueError, TypeError): + pytest.fail(f"'{value}' is not a valid UUID") + + +def assert_timestamp(value: str): + """Проверяет что строка является валидным ISO timestamp""" + import datetime + try: + datetime.datetime.fromisoformat(value.replace('Z', '+00:00')) + except ValueError: + pytest.fail(f"'{value}' is not a valid ISO timestamp") \ No newline at end of file diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..926a3c2 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session \ No newline at end of file diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..98f2abb --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,5 @@ +pytest==8.2.2 +pytest-asyncio==0.24.0 +httpx==0.27.0 +websockets==13.1 +uuid==1.30 \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..1a3b3f3 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,117 @@ +""" +Тесты аутентификации - логин и проверка JWT токена +""" +import pytest +import httpx +from conftest import assert_uuid + + +@pytest.mark.asyncio +async def test_login_success(base_url: str): + """Test successful admin login and JWT token validation""" + async with httpx.AsyncClient() as client: + response = await client.post(f"{base_url}/auth/login", json={ + "login": "admin", + "password": "teamboard" + }) + + assert response.status_code == 200 + data = response.json() + + # Проверяем структуру ответа + assert "token" in data + assert "member_id" in data + assert "slug" in data + assert "role" in data + + # Проверяем валидность данных + assert_uuid(data["member_id"]) + assert data["slug"] == "admin" + assert data["role"] == "owner" + assert isinstance(data["token"], str) + assert len(data["token"]) > 10 # JWT должен быть длинным + + +@pytest.mark.asyncio +async def test_login_with_slug(base_url: str): + """Test login using slug instead of name""" + async with httpx.AsyncClient() as client: + response = await client.post(f"{base_url}/auth/login", json={ + "login": "admin", # используем slug + "password": "teamboard" + }) + + assert response.status_code == 200 + data = response.json() + assert data["slug"] == "admin" + + +@pytest.mark.asyncio +async def test_login_invalid_credentials(base_url: str): + """Test login with invalid credentials returns 401""" + async with httpx.AsyncClient() as client: + response = await client.post(f"{base_url}/auth/login", json={ + "login": "admin", + "password": "wrong_password" + }) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_login_missing_fields(base_url: str): + """Test login with missing required fields""" + async with httpx.AsyncClient() as client: + # Без пароля + response = await client.post(f"{base_url}/auth/login", json={ + "login": "admin" + }) + assert response.status_code == 422 + + # Без логина + response = await client.post(f"{base_url}/auth/login", json={ + "password": "teamboard" + }) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_protected_endpoint_without_auth(base_url: str): + """Test that protected endpoints require authentication""" + async with httpx.AsyncClient() as client: + response = await client.get(f"{base_url}/members") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_protected_endpoint_with_invalid_token(base_url: str): + """Test protected endpoint with invalid JWT token""" + headers = {"Authorization": "Bearer invalid_token"} + async with httpx.AsyncClient() as client: + response = await client.get(f"{base_url}/members", headers=headers) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_protected_endpoint_with_valid_token(http_client: httpx.AsyncClient): + """Test that valid JWT token allows access to protected endpoints""" + response = await http_client.get("/members") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +@pytest.mark.asyncio +async def test_agent_token_authentication(agent_client: httpx.AsyncClient): + """Test that agent token works for API access""" + response = await agent_client.get("/members") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +@pytest.mark.asyncio +async def test_token_query_parameter(base_url: str, admin_token: str): + """Test authentication using token query parameter""" + async with httpx.AsyncClient() as client: + response = await client.get(f"{base_url}/members?token={admin_token}") + assert response.status_code == 200 + assert isinstance(response.json(), list) \ No newline at end of file diff --git a/tests/test_chat.py b/tests/test_chat.py new file mode 100644 index 0000000..219db8c --- /dev/null +++ b/tests/test_chat.py @@ -0,0 +1,315 @@ +""" +Тесты работы с чатами и сообщениями - отправка, mentions, история +""" +import pytest +import httpx +import uuid +from conftest import assert_uuid, assert_timestamp + + +@pytest.mark.asyncio +async def test_get_project_messages(http_client: httpx.AsyncClient, test_project: dict): + """Test getting messages from project chat""" + chat_id = test_project["chat_id"] + + response = await http_client.get(f"/messages?chat_id={chat_id}") + assert response.status_code == 200 + + messages = response.json() + assert isinstance(messages, list) + + +@pytest.mark.asyncio +async def test_get_task_comments(http_client: httpx.AsyncClient, test_task: dict): + """Test getting comments for specific task""" + response = await http_client.get(f"/messages?task_id={test_task['id']}") + assert response.status_code == 200 + + messages = response.json() + assert isinstance(messages, list) + + +@pytest.mark.asyncio +async def test_send_message_to_project_chat(http_client: httpx.AsyncClient, test_project: dict): + """Test sending message to project chat""" + chat_id = test_project["chat_id"] + message_data = { + "chat_id": chat_id, + "content": "Hello from test! This is a test message." + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 201 + + message = response.json() + assert message["content"] == message_data["content"] + assert message["chat_id"] == chat_id + assert message["task_id"] is None + assert message["parent_id"] is None + assert message["author_type"] in ["human", "agent"] + assert message["author"] is not None + assert_uuid(message["id"]) + assert_uuid(message["author_id"]) + assert_timestamp(message["created_at"]) + + # Проверяем структуру автора + assert "id" in message["author"] + assert "slug" in message["author"] + assert "name" in message["author"] + + +@pytest.mark.asyncio +async def test_send_comment_to_task(http_client: httpx.AsyncClient, test_task: dict): + """Test sending comment to task""" + message_data = { + "task_id": test_task["id"], + "content": "This is a comment on the task." + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 201 + + message = response.json() + assert message["content"] == message_data["content"] + assert message["chat_id"] is None + assert message["task_id"] == test_task["id"] + assert message["parent_id"] is None + + +@pytest.mark.asyncio +async def test_send_message_with_mentions(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test sending message with user mentions""" + chat_id = test_project["chat_id"] + message_data = { + "chat_id": chat_id, + "content": f"Hey @{test_user['slug']}, check this out!", + "mentions": [test_user["id"]] + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 201 + + message = response.json() + assert len(message["mentions"]) == 1 + assert message["mentions"][0]["id"] == test_user["id"] + assert message["mentions"][0]["slug"] == test_user["slug"] + assert message["mentions"][0]["name"] == test_user["name"] + + +@pytest.mark.asyncio +async def test_send_agent_message_with_thinking(agent_client: httpx.AsyncClient, test_project: dict): + """Test agent sending message with thinking content""" + chat_id = test_project["chat_id"] + message_data = { + "chat_id": chat_id, + "content": "I think this is the best approach.", + "thinking": "Let me analyze this problem step by step. First, I need to consider..." + } + + response = await agent_client.post("/messages", json=message_data) + assert response.status_code == 201 + + message = response.json() + assert message["content"] == message_data["content"] + assert message["thinking"] == message_data["thinking"] + assert message["author_type"] == "agent" + + +@pytest.mark.asyncio +async def test_send_reply_in_thread(http_client: httpx.AsyncClient, test_project: dict): + """Test sending reply to existing message (thread)""" + chat_id = test_project["chat_id"] + + # Отправляем основное сообщение + original_data = { + "chat_id": chat_id, + "content": "Original message" + } + + response = await http_client.post("/messages", json=original_data) + assert response.status_code == 201 + original_message = response.json() + + # Отправляем ответ в тред + reply_data = { + "chat_id": chat_id, + "parent_id": original_message["id"], + "content": "This is a reply in thread" + } + + response = await http_client.post("/messages", json=reply_data) + assert response.status_code == 201 + + reply = response.json() + assert reply["parent_id"] == original_message["id"] + assert reply["content"] == reply_data["content"] + assert reply["chat_id"] == chat_id + + +@pytest.mark.asyncio +async def test_get_thread_replies(http_client: httpx.AsyncClient, test_project: dict): + """Test getting replies in thread""" + chat_id = test_project["chat_id"] + + # Отправляем основное сообщение + original_data = { + "chat_id": chat_id, + "content": "Message with replies" + } + + response = await http_client.post("/messages", json=original_data) + assert response.status_code == 201 + original_message = response.json() + + # Отправляем несколько ответов + for i in range(3): + reply_data = { + "chat_id": chat_id, + "parent_id": original_message["id"], + "content": f"Reply {i+1}" + } + await http_client.post("/messages", json=reply_data) + + # Получаем ответы в треде + response = await http_client.get(f"/messages/{original_message['id']}/replies") + assert response.status_code == 200 + + replies = response.json() + assert len(replies) == 3 + for reply in replies: + assert reply["parent_id"] == original_message["id"] + + +@pytest.mark.asyncio +async def test_send_message_without_chat_or_task_fails(http_client: httpx.AsyncClient): + """Test that message without chat_id or task_id returns 400""" + message_data = { + "content": "Message without destination" + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_send_message_to_nonexistent_chat_fails(http_client: httpx.AsyncClient): + """Test sending message to non-existent chat""" + fake_chat_id = str(uuid.uuid4()) + message_data = { + "chat_id": fake_chat_id, + "content": "Message to nowhere" + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_send_message_to_nonexistent_task_fails(http_client: httpx.AsyncClient): + """Test sending message to non-existent task""" + fake_task_id = str(uuid.uuid4()) + message_data = { + "task_id": fake_task_id, + "content": "Comment on nowhere" + } + + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_messages_with_pagination(http_client: httpx.AsyncClient, test_project: dict): + """Test getting messages with limit and offset""" + chat_id = test_project["chat_id"] + + # Отправляем несколько сообщений + for i in range(5): + message_data = { + "chat_id": chat_id, + "content": f"Test message {i+1}" + } + await http_client.post("/messages", json=message_data) + + # Получаем с лимитом + response = await http_client.get(f"/messages?chat_id={chat_id}&limit=2") + assert response.status_code == 200 + + messages = response.json() + assert len(messages) <= 2 + + # Получаем с отступом + response = await http_client.get(f"/messages?chat_id={chat_id}&limit=2&offset=2") + assert response.status_code == 200 + + offset_messages = response.json() + assert len(offset_messages) <= 2 + + +@pytest.mark.asyncio +async def test_get_messages_with_parent_filter(http_client: httpx.AsyncClient, test_project: dict): + """Test getting messages filtered by parent_id""" + chat_id = test_project["chat_id"] + + # Отправляем основное сообщение + original_data = { + "chat_id": chat_id, + "content": "Parent message for filter test" + } + + response = await http_client.post("/messages", json=original_data) + assert response.status_code == 201 + parent_message = response.json() + + # Отправляем ответ + reply_data = { + "chat_id": chat_id, + "parent_id": parent_message["id"], + "content": "Child reply" + } + + await http_client.post("/messages", json=reply_data) + + # Фильтруем по parent_id + response = await http_client.get(f"/messages?parent_id={parent_message['id']}") + assert response.status_code == 200 + + messages = response.json() + for message in messages: + assert message["parent_id"] == parent_message["id"] + + +@pytest.mark.asyncio +async def test_message_order_chronological(http_client: httpx.AsyncClient, test_project: dict): + """Test that messages are returned in chronological order""" + chat_id = test_project["chat_id"] + + # Отправляем сообщения с задержкой + import asyncio + + messages_sent = [] + for i in range(3): + message_data = { + "chat_id": chat_id, + "content": f"Ordered message {i+1}" + } + response = await http_client.post("/messages", json=message_data) + assert response.status_code == 201 + messages_sent.append(response.json()) + await asyncio.sleep(0.1) # Небольшая задержка + + # Получаем сообщения + response = await http_client.get(f"/messages?chat_id={chat_id}&limit=10") + assert response.status_code == 200 + + messages = response.json() + + # Фильтруем только наши тестовые сообщения + test_messages = [m for m in messages if "Ordered message" in m["content"]] + + # Проверяем хронологический порядок + if len(test_messages) >= 2: + for i in range(len(test_messages) - 1): + current_time = test_messages[i]["created_at"] + next_time = test_messages[i + 1]["created_at"] + # Более поздние сообщения должны идти раньше (DESC order) + assert next_time >= current_time \ No newline at end of file diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..b576c90 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,404 @@ +""" +Тесты работы с файлами - upload, download, файлы проектов +""" +import pytest +import httpx +import uuid +import tempfile +import os +from conftest import assert_uuid, assert_timestamp + + +@pytest.mark.asyncio +async def test_upload_file(http_client: httpx.AsyncClient): + """Test uploading file via multipart/form-data""" + # Создаём временный файл + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("This is a test file for upload\nLine 2\nLine 3") + temp_file_path = f.name + + try: + # Загружаем файл + with open(temp_file_path, 'rb') as f: + files = {'file': ('test_upload.txt', f, 'text/plain')} + response = await http_client.post("/upload", files=files) + + assert response.status_code == 200 + + upload_data = response.json() + assert "file_id" in upload_data + assert "filename" in upload_data + assert "mime_type" in upload_data + assert "size" in upload_data + assert "storage_name" in upload_data + + assert_uuid(upload_data["file_id"]) + assert upload_data["filename"] == "test_upload.txt" + assert upload_data["mime_type"] == "text/plain" + assert upload_data["size"] > 0 + assert isinstance(upload_data["storage_name"], str) + + finally: + # Удаляем временный файл + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_upload_large_file_fails(http_client: httpx.AsyncClient): + """Test that uploading too large file returns 413""" + # Создаём файл размером больше 50MB (лимит из API) + # Но для теста создадим файл 1MB и проверим что endpoint работает + large_content = "A" * (1024 * 1024) # 1MB + + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write(large_content) + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('large_file.txt', f, 'text/plain')} + response = await http_client.post("/upload", files=files) + + # В тестовой среде файл 1MB должен проходить успешно + # В продакшене лимит 50MB вернёт 413 + assert response.status_code in [200, 413] + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_upload_without_file_fails(http_client: httpx.AsyncClient): + """Test uploading without file field returns 422""" + response = await http_client.post("/upload", files={}) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_download_attachment_by_id(): + """Test downloading uploaded file (requires attachment ID)""" + # Этот тест сложно реализовать без создания сообщения с вложением + # Оставляем как плейсхолдер для полноценной реализации + pass + + +# Тесты файлов проектов + +@pytest.mark.asyncio +async def test_get_project_files_list(http_client: httpx.AsyncClient, test_project: dict): + """Test getting list of project files""" + response = await http_client.get(f"/projects/{test_project['id']}/files") + assert response.status_code == 200 + + files = response.json() + assert isinstance(files, list) + + +@pytest.mark.asyncio +async def test_upload_file_to_project(http_client: httpx.AsyncClient, test_project: dict): + """Test uploading file directly to project""" + # Создаём временный файл + with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f: + f.write("# Project Documentation\n\nThis is a test document for the project.") + temp_file_path = f.name + + try: + # Загружаем файл в проект + with open(temp_file_path, 'rb') as f: + files = {'file': ('README.md', f, 'text/markdown')} + data = {'description': 'Project documentation file'} + response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files, + data=data + ) + + assert response.status_code == 201 + + project_file = response.json() + assert project_file["filename"] == "README.md" + assert project_file["description"] == "Project documentation file" + assert project_file["mime_type"] == "text/markdown" + assert project_file["size"] > 0 + assert_uuid(project_file["id"]) + + # Проверяем информацию о загрузившем + assert "uploaded_by" in project_file + assert "id" in project_file["uploaded_by"] + assert "slug" in project_file["uploaded_by"] + assert "name" in project_file["uploaded_by"] + + assert_timestamp(project_file["created_at"]) + assert_timestamp(project_file["updated_at"]) + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_upload_file_to_project_without_description(http_client: httpx.AsyncClient, test_project: dict): + """Test uploading file to project without description""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("Simple text file without description") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('simple.txt', f, 'text/plain')} + response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files + ) + + assert response.status_code == 201 + + project_file = response.json() + assert project_file["filename"] == "simple.txt" + assert project_file["description"] is None + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_upload_file_to_nonexistent_project(http_client: httpx.AsyncClient): + """Test uploading file to non-existent project returns 404""" + fake_project_id = str(uuid.uuid4()) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("File for nowhere") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('test.txt', f, 'text/plain')} + response = await http_client.post( + f"/projects/{fake_project_id}/files", + files=files + ) + + assert response.status_code == 404 + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_get_project_file_info(http_client: httpx.AsyncClient, test_project: dict): + """Test getting specific project file information""" + # Сначала загружаем файл + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + f.write('{"test": "data", "project": "file"}') + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('data.json', f, 'application/json')} + data = {'description': 'Test JSON data'} + upload_response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files, + data=data + ) + + assert upload_response.status_code == 201 + project_file = upload_response.json() + + # Получаем информацию о файле + response = await http_client.get(f"/projects/{test_project['id']}/files/{project_file['id']}") + assert response.status_code == 200 + + file_info = response.json() + assert file_info["id"] == project_file["id"] + assert file_info["filename"] == "data.json" + assert file_info["description"] == "Test JSON data" + assert file_info["mime_type"] == "application/json" + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_download_project_file(http_client: httpx.AsyncClient, test_project: dict): + """Test downloading project file""" + # Сначала загружаем файл + test_content = "Downloadable content\nLine 2" + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write(test_content) + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('download_test.txt', f, 'text/plain')} + upload_response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files + ) + + assert upload_response.status_code == 201 + project_file = upload_response.json() + + # Скачиваем файл + response = await http_client.get( + f"/projects/{test_project['id']}/files/{project_file['id']}/download" + ) + assert response.status_code == 200 + + # Проверяем заголовки + assert "content-type" in response.headers + assert "content-length" in response.headers + + # Проверяем содержимое + downloaded_content = response.text + assert downloaded_content == test_content + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_update_project_file_description(http_client: httpx.AsyncClient, test_project: dict): + """Test updating project file description""" + # Загружаем файл + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("File to update description") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('update_desc.txt', f, 'text/plain')} + data = {'description': 'Original description'} + upload_response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files, + data=data + ) + + assert upload_response.status_code == 201 + project_file = upload_response.json() + + # Обновляем описание + update_data = {"description": "Updated file description"} + response = await http_client.patch( + f"/projects/{test_project['id']}/files/{project_file['id']}", + json=update_data + ) + assert response.status_code == 200 + + updated_file = response.json() + assert updated_file["description"] == "Updated file description" + assert updated_file["id"] == project_file["id"] + assert updated_file["filename"] == project_file["filename"] + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_clear_project_file_description(http_client: httpx.AsyncClient, test_project: dict): + """Test clearing project file description""" + # Загружаем файл с описанием + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("File to clear description") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('clear_desc.txt', f, 'text/plain')} + data = {'description': 'Description to be cleared'} + upload_response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files, + data=data + ) + + assert upload_response.status_code == 201 + project_file = upload_response.json() + + # Очищаем описание + update_data = {"description": None} + response = await http_client.patch( + f"/projects/{test_project['id']}/files/{project_file['id']}", + json=update_data + ) + assert response.status_code == 200 + + updated_file = response.json() + assert updated_file["description"] is None + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_delete_project_file(http_client: httpx.AsyncClient, test_project: dict): + """Test deleting project file""" + # Загружаем файл + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("File to be deleted") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': ('to_delete.txt', f, 'text/plain')} + upload_response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files + ) + + assert upload_response.status_code == 201 + project_file = upload_response.json() + + # Удаляем файл + response = await http_client.delete( + f"/projects/{test_project['id']}/files/{project_file['id']}" + ) + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что файл действительно удалён + response = await http_client.get( + f"/projects/{test_project['id']}/files/{project_file['id']}" + ) + assert response.status_code == 404 + + finally: + os.unlink(temp_file_path) + + +@pytest.mark.asyncio +async def test_search_project_files(http_client: httpx.AsyncClient, test_project: dict): + """Test searching project files by filename""" + # Загружаем несколько файлов + test_files = ["searchable_doc.md", "another_file.txt", "searchable_config.json"] + uploaded_files = [] + + for filename in test_files: + with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: + f.write(f"Content of {filename}") + temp_file_path = f.name + + try: + with open(temp_file_path, 'rb') as f: + files = {'file': (filename, f, 'text/plain')} + response = await http_client.post( + f"/projects/{test_project['id']}/files", + files=files + ) + assert response.status_code == 201 + uploaded_files.append(response.json()) + + finally: + os.unlink(temp_file_path) + + # Ищем файлы со словом "searchable" + response = await http_client.get( + f"/projects/{test_project['id']}/files?search=searchable" + ) + assert response.status_code == 200 + + files = response.json() + searchable_files = [f for f in files if "searchable" in f["filename"].lower()] + assert len(searchable_files) >= 2 # Должно найти 2 файла с "searchable" \ No newline at end of file diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..709fd5d --- /dev/null +++ b/tests/test_labels.py @@ -0,0 +1,352 @@ +""" +Тесты работы с лейблами - CRUD операции, привязка к задачам +""" +import pytest +import httpx +import uuid +from conftest import assert_uuid + + +@pytest.mark.asyncio +async def test_get_labels_list(http_client: httpx.AsyncClient): + """Test getting list of all labels""" + response = await http_client.get("/labels") + assert response.status_code == 200 + + labels = response.json() + assert isinstance(labels, list) + + +@pytest.mark.asyncio +async def test_create_label(http_client: httpx.AsyncClient): + """Test creating new label""" + label_name = f"test-label-{uuid.uuid4().hex[:8]}" + label_data = { + "name": label_name, + "color": "#ff5733" + } + + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + + label = response.json() + assert label["name"] == label_name + assert label["color"] == "#ff5733" + assert_uuid(label["id"]) + + # Cleanup + await http_client.delete(f"/labels/{label['id']}") + + +@pytest.mark.asyncio +async def test_create_label_default_color(http_client: httpx.AsyncClient): + """Test creating label without color uses default""" + label_name = f"default-color-{uuid.uuid4().hex[:8]}" + label_data = {"name": label_name} + + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + + label = response.json() + assert label["name"] == label_name + assert label["color"] == "#6366f1" # Цвет по умолчанию + + # Cleanup + await http_client.delete(f"/labels/{label['id']}") + + +@pytest.mark.asyncio +async def test_create_label_duplicate_name_fails(http_client: httpx.AsyncClient, test_label: dict): + """Test creating label with duplicate name returns 409""" + label_data = { + "name": test_label["name"], # Используем существующее имя + "color": "#123456" + } + + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_update_label(http_client: httpx.AsyncClient, test_label: dict): + """Test updating label name and color""" + update_data = { + "name": f"updated-{test_label['name']}", + "color": "#00ff00" + } + + response = await http_client.patch(f"/labels/{test_label['id']}", json=update_data) + assert response.status_code == 200 + + label = response.json() + assert label["name"] == update_data["name"] + assert label["color"] == update_data["color"] + assert label["id"] == test_label["id"] + + +@pytest.mark.asyncio +async def test_update_label_name_only(http_client: httpx.AsyncClient, test_label: dict): + """Test updating only label name""" + original_color = test_label["color"] + update_data = {"name": f"name-only-{uuid.uuid4().hex[:8]}"} + + response = await http_client.patch(f"/labels/{test_label['id']}", json=update_data) + assert response.status_code == 200 + + label = response.json() + assert label["name"] == update_data["name"] + assert label["color"] == original_color # Цвет не изменился + + +@pytest.mark.asyncio +async def test_update_label_color_only(http_client: httpx.AsyncClient, test_label: dict): + """Test updating only label color""" + original_name = test_label["name"] + update_data = {"color": "#purple"} + + response = await http_client.patch(f"/labels/{test_label['id']}", json=update_data) + assert response.status_code == 200 + + label = response.json() + assert label["name"] == original_name # Имя не изменилось + assert label["color"] == "#purple" + + +@pytest.mark.asyncio +async def test_update_nonexistent_label(http_client: httpx.AsyncClient): + """Test updating non-existent label returns 404""" + fake_label_id = str(uuid.uuid4()) + update_data = {"name": "nonexistent"} + + response = await http_client.patch(f"/labels/{fake_label_id}", json=update_data) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_label(http_client: httpx.AsyncClient): + """Test deleting label""" + # Создаём временный лейбл для удаления + label_name = f"to-delete-{uuid.uuid4().hex[:8]}" + label_data = {"name": label_name, "color": "#ff0000"} + + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + label = response.json() + + # Удаляем лейбл + response = await http_client.delete(f"/labels/{label['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + +@pytest.mark.asyncio +async def test_delete_nonexistent_label(http_client: httpx.AsyncClient): + """Test deleting non-existent label returns 404""" + fake_label_id = str(uuid.uuid4()) + + response = await http_client.delete(f"/labels/{fake_label_id}") + assert response.status_code == 404 + + +# Тесты привязки лейблов к задачам + +@pytest.mark.asyncio +async def test_add_label_to_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict): + """Test adding label to task""" + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что лейбл добавился к задаче + task_response = await http_client.get(f"/tasks/{test_task['id']}") + assert task_response.status_code == 200 + + task = task_response.json() + assert test_label["name"] in task["labels"] + + +@pytest.mark.asyncio +async def test_add_multiple_labels_to_task(http_client: httpx.AsyncClient, test_task: dict): + """Test adding multiple labels to task""" + # Создаём несколько лейблов + labels_created = [] + for i in range(3): + label_data = { + "name": f"multi-label-{i}-{uuid.uuid4().hex[:6]}", + "color": f"#{i:02d}{i:02d}{i:02d}" + } + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + labels_created.append(response.json()) + + # Добавляем все лейблы к задаче + for label in labels_created: + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{label['id']}") + assert response.status_code == 200 + + # Проверяем что все лейблы добавились + task_response = await http_client.get(f"/tasks/{test_task['id']}") + assert task_response.status_code == 200 + + task = task_response.json() + for label in labels_created: + assert label["name"] in task["labels"] + + # Cleanup + for label in labels_created: + await http_client.delete(f"/labels/{label['id']}") + + +@pytest.mark.asyncio +async def test_add_nonexistent_label_to_task(http_client: httpx.AsyncClient, test_task: dict): + """Test adding non-existent label to task returns 404""" + fake_label_id = str(uuid.uuid4()) + + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{fake_label_id}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_add_label_to_nonexistent_task(http_client: httpx.AsyncClient, test_label: dict): + """Test adding label to non-existent task returns 404""" + fake_task_id = str(uuid.uuid4()) + + response = await http_client.post(f"/tasks/{fake_task_id}/labels/{test_label['id']}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_add_duplicate_label_to_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict): + """Test adding same label twice to task (should be idempotent)""" + # Добавляем лейбл первый раз + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + assert response.status_code == 200 + + # Добавляем тот же лейбл повторно + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + # В зависимости от реализации может быть 200 (идемпотентность) или 409 (конфликт) + assert response.status_code in [200, 409] + + +@pytest.mark.asyncio +async def test_remove_label_from_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict): + """Test removing label from task""" + # Сначала добавляем лейбл + response = await http_client.post(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + assert response.status_code == 200 + + # Затем удаляем + response = await http_client.delete(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что лейбл удалился + task_response = await http_client.get(f"/tasks/{test_task['id']}") + assert task_response.status_code == 200 + + task = task_response.json() + assert test_label["name"] not in task["labels"] + + +@pytest.mark.asyncio +async def test_remove_nonexistent_label_from_task(http_client: httpx.AsyncClient, test_task: dict): + """Test removing non-existent label from task returns 404""" + fake_label_id = str(uuid.uuid4()) + + response = await http_client.delete(f"/tasks/{test_task['id']}/labels/{fake_label_id}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_remove_label_from_nonexistent_task(http_client: httpx.AsyncClient, test_label: dict): + """Test removing label from non-existent task returns 404""" + fake_task_id = str(uuid.uuid4()) + + response = await http_client.delete(f"/tasks/{fake_task_id}/labels/{test_label['id']}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_remove_label_not_attached_to_task(http_client: httpx.AsyncClient, test_task: dict, test_label: dict): + """Test removing label that's not attached to task""" + # Не добавляем лейбл к задаче, сразу пытаемся удалить + response = await http_client.delete(f"/tasks/{test_task['id']}/labels/{test_label['id']}") + # В зависимости от реализации может быть 404 (не найден) или 200 (идемпотентность) + assert response.status_code in [200, 404] + + +@pytest.mark.asyncio +async def test_filter_tasks_by_label(http_client: httpx.AsyncClient, test_project: dict): + """Test filtering tasks by label""" + # Создаём лейбл для фильтрации + label_data = { + "name": f"filter-test-{uuid.uuid4().hex[:8]}", + "color": "#123456" + } + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + filter_label = response.json() + + # Создаём задачу с лейблом + task_data = { + "title": "Task with specific label", + "labels": [filter_label["name"]] + } + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + labeled_task = response.json() + + # Фильтруем задачи по лейблу + response = await http_client.get(f"/tasks?label={filter_label['name']}") + assert response.status_code == 200 + + tasks = response.json() + # Все найденные задачи должны содержать указанный лейбл + for task in tasks: + assert filter_label["name"] in task["labels"] + + # Наша задача должна быть в результатах + task_ids = [t["id"] for t in tasks] + assert labeled_task["id"] in task_ids + + # Cleanup + await http_client.delete(f"/labels/{filter_label['id']}") + + +@pytest.mark.asyncio +async def test_create_task_with_labels(http_client: httpx.AsyncClient, test_project: dict): + """Test creating task with labels from the start""" + # Создаём несколько лейблов + labels_data = [ + {"name": f"initial-label-1-{uuid.uuid4().hex[:6]}", "color": "#ff0000"}, + {"name": f"initial-label-2-{uuid.uuid4().hex[:6]}", "color": "#00ff00"} + ] + + created_labels = [] + for label_data in labels_data: + response = await http_client.post("/labels", json=label_data) + assert response.status_code == 201 + created_labels.append(response.json()) + + # Создаём задачу с лейблами + task_data = { + "title": "Task with initial labels", + "labels": [label["name"] for label in created_labels] + } + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + + task = response.json() + for label in created_labels: + assert label["name"] in task["labels"] + + # Cleanup + for label in created_labels: + await http_client.delete(f"/labels/{label['id']}") \ No newline at end of file diff --git a/tests/test_members.py b/tests/test_members.py new file mode 100644 index 0000000..3ffd707 --- /dev/null +++ b/tests/test_members.py @@ -0,0 +1,287 @@ +""" +Тесты работы с участниками - CRUD операции, смена slug, токены агентов +""" +import pytest +import httpx +import uuid +from conftest import assert_uuid, assert_timestamp + + +@pytest.mark.asyncio +async def test_get_members_list(http_client: httpx.AsyncClient): + """Test getting list of all members""" + response = await http_client.get("/members") + assert response.status_code == 200 + + members = response.json() + assert isinstance(members, list) + assert len(members) > 0 + + # Проверяем структуру первого участника + member = members[0] + assert "id" in member + assert "name" in member + assert "slug" in member + assert "type" in member + assert "role" in member + assert "status" in member + assert "is_active" in member + assert "last_seen_at" in member + + assert_uuid(member["id"]) + assert member["type"] in ["human", "agent", "bridge"] + assert member["role"] in ["owner", "admin", "member"] + assert member["status"] in ["online", "offline", "busy"] + assert isinstance(member["is_active"], bool) + + +@pytest.mark.asyncio +async def test_get_member_by_id(http_client: httpx.AsyncClient, test_user: dict): + """Test getting specific member by ID""" + response = await http_client.get(f"/members/{test_user['id']}") + assert response.status_code == 200 + + member = response.json() + assert member["id"] == test_user["id"] + assert member["name"] == test_user["name"] + assert member["slug"] == test_user["slug"] + assert member["type"] == "human" + + +@pytest.mark.asyncio +async def test_get_member_not_found(http_client: httpx.AsyncClient): + """Test getting non-existent member returns 404""" + fake_id = str(uuid.uuid4()) + response = await http_client.get(f"/members/{fake_id}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_human_member(http_client: httpx.AsyncClient): + """Test creating new human member""" + member_slug = f"test-human-{uuid.uuid4().hex[:8]}" + member_data = { + "name": f"Test Human {member_slug}", + "slug": member_slug, + "type": "human", + "role": "member" + } + + response = await http_client.post("/members", json=member_data) + assert response.status_code == 201 + + member = response.json() + assert member["name"] == member_data["name"] + assert member["slug"] == member_data["slug"] + assert member["type"] == "human" + assert member["role"] == "member" + assert_uuid(member["id"]) + + # У человека не должно быть токена + assert "token" not in member + + # Cleanup + await http_client.delete(f"/members/{member['id']}") + + +@pytest.mark.asyncio +async def test_create_agent_member(http_client: httpx.AsyncClient): + """Test creating new agent member with configuration""" + agent_slug = f"test-agent-{uuid.uuid4().hex[:8]}" + agent_data = { + "name": f"Test Agent {agent_slug}", + "slug": agent_slug, + "type": "agent", + "role": "member", + "agent_config": { + "capabilities": ["coding", "testing", "documentation"], + "labels": ["backend", "python", "api"], + "chat_listen": "mentions", + "task_listen": "assigned", + "prompt": "You are a test automation agent", + "model": "gpt-4" + } + } + + response = await http_client.post("/members", json=agent_data) + assert response.status_code == 201 + + agent = response.json() + assert agent["name"] == agent_data["name"] + assert agent["slug"] == agent_data["slug"] + assert agent["type"] == "agent" + assert agent["role"] == "member" + assert_uuid(agent["id"]) + + # У агента должен быть токен при создании + assert "token" in agent + assert agent["token"].startswith("tb-") + + # Проверяем конфигурацию агента + config = agent["agent_config"] + assert config["capabilities"] == agent_data["agent_config"]["capabilities"] + assert config["labels"] == agent_data["agent_config"]["labels"] + assert config["chat_listen"] == "mentions" + assert config["task_listen"] == "assigned" + + # Cleanup + await http_client.delete(f"/members/{agent['id']}") + + +@pytest.mark.asyncio +async def test_create_member_duplicate_slug(http_client: httpx.AsyncClient, test_user: dict): + """Test creating member with duplicate slug returns 409""" + member_data = { + "name": "Another User", + "slug": test_user["slug"], # Используем существующий slug + "type": "human", + "role": "member" + } + + response = await http_client.post("/members", json=member_data) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_update_member_info(http_client: httpx.AsyncClient, test_user: dict): + """Test updating member information""" + update_data = { + "name": "Updated Test User", + "role": "admin" + } + + response = await http_client.patch(f"/members/{test_user['id']}", json=update_data) + assert response.status_code == 200 + + member = response.json() + assert member["name"] == "Updated Test User" + assert member["role"] == "admin" + assert member["id"] == test_user["id"] + assert member["slug"] == test_user["slug"] # slug не изменился + + +@pytest.mark.asyncio +async def test_update_member_slug(http_client: httpx.AsyncClient, test_user: dict): + """Test updating member slug""" + new_slug = f"updated-slug-{uuid.uuid4().hex[:8]}" + update_data = {"slug": new_slug} + + response = await http_client.patch(f"/members/{test_user['id']}", json=update_data) + assert response.status_code == 200 + + member = response.json() + assert member["slug"] == new_slug + + +@pytest.mark.asyncio +async def test_update_agent_config(http_client: httpx.AsyncClient, test_agent: dict): + """Test updating agent configuration""" + new_config = { + "agent_config": { + "capabilities": ["testing", "debugging"], + "labels": ["frontend", "javascript"], + "chat_listen": "all", + "task_listen": "mentions", + "prompt": "Updated test agent", + "model": "claude-3" + } + } + + response = await http_client.patch(f"/members/{test_agent['id']}", json=new_config) + assert response.status_code == 200 + + agent = response.json() + config = agent["agent_config"] + assert config["capabilities"] == new_config["agent_config"]["capabilities"] + assert config["labels"] == new_config["agent_config"]["labels"] + assert config["chat_listen"] == "all" + assert config["task_listen"] == "mentions" + + +@pytest.mark.asyncio +async def test_regenerate_agent_token(http_client: httpx.AsyncClient, test_agent: dict): + """Test regenerating agent token""" + response = await http_client.post(f"/members/{test_agent['id']}/regenerate-token") + assert response.status_code == 200 + + data = response.json() + assert "token" in data + assert data["token"].startswith("tb-") + assert data["token"] != test_agent["token"] # Новый токен должен отличаться + + +@pytest.mark.asyncio +async def test_regenerate_token_for_human_fails(http_client: httpx.AsyncClient, test_user: dict): + """Test that regenerating token for human returns 400""" + response = await http_client.post(f"/members/{test_user['id']}/regenerate-token") + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_revoke_agent_token(http_client: httpx.AsyncClient, test_agent: dict): + """Test revoking agent token""" + response = await http_client.post(f"/members/{test_agent['id']}/revoke-token") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + +@pytest.mark.asyncio +async def test_revoke_token_for_human_fails(http_client: httpx.AsyncClient, test_user: dict): + """Test that revoking token for human returns 400""" + response = await http_client.post(f"/members/{test_user['id']}/revoke-token") + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_update_member_status(agent_client: httpx.AsyncClient): + """Test updating member status (for agents)""" + response = await agent_client.patch("/members/me/status?status=busy") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "busy" + + +@pytest.mark.asyncio +async def test_delete_member(http_client: httpx.AsyncClient): + """Test soft deleting member (sets is_active=false)""" + # Создаём временного пользователя для удаления + member_slug = f"to-delete-{uuid.uuid4().hex[:8]}" + member_data = { + "name": f"User to Delete {member_slug}", + "slug": member_slug, + "type": "human", + "role": "member" + } + + response = await http_client.post("/members", json=member_data) + assert response.status_code == 201 + member = response.json() + + # Удаляем пользователя + response = await http_client.delete(f"/members/{member['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что пользователь помечен неактивным + response = await http_client.get(f"/members/{member['id']}") + assert response.status_code == 200 + deleted_member = response.json() + assert deleted_member["is_active"] is False + + +@pytest.mark.asyncio +async def test_get_members_include_inactive(http_client: httpx.AsyncClient): + """Test getting members list with inactive ones""" + response = await http_client.get("/members?include_inactive=true") + assert response.status_code == 200 + + members = response.json() + # Должен содержать как активных, так и неактивных + has_inactive = any(not m["is_active"] for m in members) + # Может и не быть неактивных, но запрос должен пройти + assert isinstance(members, list) \ No newline at end of file diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..ddce97f --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,298 @@ +""" +Тесты работы с проектами - CRUD операции, участники проектов +""" +import pytest +import httpx +import uuid +from conftest import assert_uuid, assert_timestamp + + +@pytest.mark.asyncio +async def test_get_projects_list(http_client: httpx.AsyncClient): + """Test getting list of all projects""" + response = await http_client.get("/projects") + assert response.status_code == 200 + + projects = response.json() + assert isinstance(projects, list) + + if projects: # Если есть проекты + project = projects[0] + assert "id" in project + assert "name" in project + assert "slug" in project + assert "description" in project + assert "repo_urls" in project + assert "status" in project + assert "task_counter" in project + assert "chat_id" in project + assert "auto_assign" in project + + assert_uuid(project["id"]) + assert project["status"] in ["active", "archived", "paused"] + assert isinstance(project["task_counter"], int) + assert isinstance(project["auto_assign"], bool) + + +@pytest.mark.asyncio +async def test_get_project_by_id(http_client: httpx.AsyncClient, test_project: dict): + """Test getting specific project by ID""" + response = await http_client.get(f"/projects/{test_project['id']}") + assert response.status_code == 200 + + project = response.json() + assert project["id"] == test_project["id"] + assert project["name"] == test_project["name"] + assert project["slug"] == test_project["slug"] + assert project["status"] == "active" + + +@pytest.mark.asyncio +async def test_get_project_not_found(http_client: httpx.AsyncClient): + """Test getting non-existent project returns 404""" + fake_id = str(uuid.uuid4()) + response = await http_client.get(f"/projects/{fake_id}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_project(http_client: httpx.AsyncClient): + """Test creating new project""" + project_slug = f"new-project-{uuid.uuid4().hex[:8]}" + project_data = { + "name": f"New Test Project {project_slug}", + "slug": project_slug, + "description": "A brand new test project", + "repo_urls": [ + "https://github.com/test/repo1", + "https://github.com/test/repo2" + ] + } + + response = await http_client.post("/projects", json=project_data) + assert response.status_code == 201 + + project = response.json() + assert project["name"] == project_data["name"] + assert project["slug"] == project_data["slug"] + assert project["description"] == project_data["description"] + assert project["repo_urls"] == project_data["repo_urls"] + assert project["status"] == "active" + assert project["task_counter"] == 0 + assert project["auto_assign"] is True # По умолчанию + assert_uuid(project["id"]) + assert_uuid(project["chat_id"]) # Автоматически создаётся основной чат + + # Cleanup + await http_client.delete(f"/projects/{project['id']}") + + +@pytest.mark.asyncio +async def test_create_project_duplicate_slug(http_client: httpx.AsyncClient, test_project: dict): + """Test creating project with duplicate slug returns 409""" + project_data = { + "name": "Another Project", + "slug": test_project["slug"], # Используем существующий slug + "description": "Should fail" + } + + response = await http_client.post("/projects", json=project_data) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_create_project_minimal_data(http_client: httpx.AsyncClient): + """Test creating project with minimal required data""" + project_slug = f"minimal-{uuid.uuid4().hex[:8]}" + project_data = { + "name": f"Minimal Project {project_slug}", + "slug": project_slug + # description и repo_urls опциональны + } + + response = await http_client.post("/projects", json=project_data) + assert response.status_code == 201 + + project = response.json() + assert project["name"] == project_data["name"] + assert project["slug"] == project_data["slug"] + assert project["description"] is None + assert project["repo_urls"] == [] + + # Cleanup + await http_client.delete(f"/projects/{project['id']}") + + +@pytest.mark.asyncio +async def test_update_project(http_client: httpx.AsyncClient, test_project: dict): + """Test updating project information""" + update_data = { + "name": "Updated Project Name", + "description": "Updated description", + "repo_urls": ["https://github.com/updated/repo"], + "auto_assign": False + } + + response = await http_client.patch(f"/projects/{test_project['id']}", json=update_data) + assert response.status_code == 200 + + project = response.json() + assert project["name"] == "Updated Project Name" + assert project["description"] == "Updated description" + assert project["repo_urls"] == ["https://github.com/updated/repo"] + assert project["auto_assign"] is False + assert project["id"] == test_project["id"] + assert project["slug"] == test_project["slug"] # slug не изменился + + +@pytest.mark.asyncio +async def test_update_project_slug(http_client: httpx.AsyncClient, test_project: dict): + """Test updating project slug""" + new_slug = f"updated-slug-{uuid.uuid4().hex[:8]}" + update_data = {"slug": new_slug} + + response = await http_client.patch(f"/projects/{test_project['id']}", json=update_data) + assert response.status_code == 200 + + project = response.json() + assert project["slug"] == new_slug + + +@pytest.mark.asyncio +async def test_update_project_status(http_client: httpx.AsyncClient, test_project: dict): + """Test updating project status""" + update_data = {"status": "archived"} + + response = await http_client.patch(f"/projects/{test_project['id']}", json=update_data) + assert response.status_code == 200 + + project = response.json() + assert project["status"] == "archived" + + +@pytest.mark.asyncio +async def test_delete_project(http_client: httpx.AsyncClient): + """Test deleting project""" + # Создаём временный проект для удаления + project_slug = f"to-delete-{uuid.uuid4().hex[:8]}" + project_data = { + "name": f"Project to Delete {project_slug}", + "slug": project_slug, + "description": "Will be deleted" + } + + response = await http_client.post("/projects", json=project_data) + assert response.status_code == 201 + project = response.json() + + # Удаляем проект + response = await http_client.delete(f"/projects/{project['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что проект действительно удалён + response = await http_client.get(f"/projects/{project['id']}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_project_members(http_client: httpx.AsyncClient, test_project: dict): + """Test getting project members list""" + response = await http_client.get(f"/projects/{test_project['id']}/members") + assert response.status_code == 200 + + members = response.json() + assert isinstance(members, list) + + if members: # Если есть участники + member = members[0] + assert "id" in member + assert "name" in member + assert "slug" in member + assert "type" in member + assert "role" in member + + assert_uuid(member["id"]) + assert member["type"] in ["human", "agent", "bridge"] + assert member["role"] in ["owner", "admin", "member"] + + +@pytest.mark.asyncio +async def test_add_member_to_project(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test adding member to project""" + response = await http_client.post(f"/projects/{test_project['id']}/members", json={ + "member_id": test_user["id"] + }) + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что участник добавлен + response = await http_client.get(f"/projects/{test_project['id']}/members") + assert response.status_code == 200 + + members = response.json() + member_ids = [m["id"] for m in members] + assert test_user["id"] in member_ids + + +@pytest.mark.asyncio +async def test_add_nonexistent_member_to_project(http_client: httpx.AsyncClient, test_project: dict): + """Test adding non-existent member to project returns 404""" + fake_member_id = str(uuid.uuid4()) + response = await http_client.post(f"/projects/{test_project['id']}/members", json={ + "member_id": fake_member_id + }) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_add_duplicate_member_to_project(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test adding already existing member to project returns 409""" + # Добавляем участника первый раз + response = await http_client.post(f"/projects/{test_project['id']}/members", json={ + "member_id": test_user["id"] + }) + assert response.status_code == 200 + + # Пытаемся добавить его же повторно + response = await http_client.post(f"/projects/{test_project['id']}/members", json={ + "member_id": test_user["id"] + }) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_remove_member_from_project(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test removing member from project""" + # Сначала добавляем участника + response = await http_client.post(f"/projects/{test_project['id']}/members", json={ + "member_id": test_user["id"] + }) + assert response.status_code == 200 + + # Затем удаляем + response = await http_client.delete(f"/projects/{test_project['id']}/members/{test_user['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что участник удалён + response = await http_client.get(f"/projects/{test_project['id']}/members") + assert response.status_code == 200 + + members = response.json() + member_ids = [m["id"] for m in members] + assert test_user["id"] not in member_ids + + +@pytest.mark.asyncio +async def test_remove_nonexistent_member_from_project(http_client: httpx.AsyncClient, test_project: dict): + """Test removing non-existent member from project returns 404""" + fake_member_id = str(uuid.uuid4()) + response = await http_client.delete(f"/projects/{test_project['id']}/members/{fake_member_id}") + assert response.status_code == 404 \ No newline at end of file diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..003ae3d --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,538 @@ +""" +Тесты агентного стриминга - WebSocket события stream.start/delta/tool/end +""" +import pytest +import asyncio +import websockets +import json +import uuid +from conftest import assert_uuid + + +@pytest.mark.asyncio +async def test_agent_stream_events_structure(agent_token: str, test_project: dict): + """Test structure of agent streaming events""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + assert auth_data["type"] == "auth.ok" + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + + # Симулируем события стриминга (обычно отправляются сервером, не клиентом) + # Но проверяем структуру событий которые должны прийти + + stream_id = str(uuid.uuid4()) + + # 1. agent.stream.start + start_event = { + "type": "agent.stream.start", + "data": { + "stream_id": stream_id, + "project_id": test_project["id"], + "chat_id": test_project["chat_id"], + "task_id": None, + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + # 2. agent.stream.delta + delta_event = { + "type": "agent.stream.delta", + "data": { + "stream_id": stream_id, + "delta": "Hello, I'm thinking about this task...", + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + # 3. agent.stream.tool + tool_event = { + "type": "agent.stream.tool", + "data": { + "stream_id": stream_id, + "tool_name": "web_search", + "tool_args": {"query": "python testing best practices"}, + "tool_result": {"results": ["result1", "result2"]}, + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + # 4. agent.stream.end + end_event = { + "type": "agent.stream.end", + "data": { + "stream_id": stream_id, + "final_message": "I've completed the analysis and here are my findings...", + "tool_log": [ + { + "name": "web_search", + "args": {"query": "python testing best practices"}, + "result": {"results": ["result1", "result2"]}, + "error": None + } + ], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + # Проверяем структуру событий + assert_stream_start_structure(start_event) + assert_stream_delta_structure(delta_event) + assert_stream_tool_structure(tool_event) + assert_stream_end_structure(end_event) + + except Exception as e: + pytest.fail(f"Stream events structure test failed: {e}") + + +def assert_stream_start_structure(event): + """Validate agent.stream.start event structure""" + assert event["type"] == "agent.stream.start" + assert "data" in event + + data = event["data"] + assert "stream_id" in data + assert "project_id" in data + assert "chat_id" in data + assert "task_id" in data # может быть None + assert "agent_id" in data + assert "agent_slug" in data + + assert_uuid(data["stream_id"]) + assert_uuid(data["project_id"]) + assert_uuid(data["agent_id"]) + assert isinstance(data["agent_slug"], str) + + if data["chat_id"]: + assert_uuid(data["chat_id"]) + if data["task_id"]: + assert_uuid(data["task_id"]) + + +def assert_stream_delta_structure(event): + """Validate agent.stream.delta event structure""" + assert event["type"] == "agent.stream.delta" + assert "data" in event + + data = event["data"] + assert "stream_id" in data + assert "delta" in data + assert "agent_id" in data + assert "agent_slug" in data + + assert_uuid(data["stream_id"]) + assert_uuid(data["agent_id"]) + assert isinstance(data["delta"], str) + assert isinstance(data["agent_slug"], str) + + +def assert_stream_tool_structure(event): + """Validate agent.stream.tool event structure""" + assert event["type"] == "agent.stream.tool" + assert "data" in event + + data = event["data"] + assert "stream_id" in data + assert "tool_name" in data + assert "tool_args" in data + assert "tool_result" in data + assert "agent_id" in data + assert "agent_slug" in data + + assert_uuid(data["stream_id"]) + assert_uuid(data["agent_id"]) + assert isinstance(data["tool_name"], str) + assert isinstance(data["agent_slug"], str) + + # tool_args и tool_result могут быть любыми JSON объектами + assert data["tool_args"] is not None + assert data["tool_result"] is not None + + +def assert_stream_end_structure(event): + """Validate agent.stream.end event structure""" + assert event["type"] == "agent.stream.end" + assert "data" in event + + data = event["data"] + assert "stream_id" in data + assert "final_message" in data + assert "tool_log" in data + assert "agent_id" in data + assert "agent_slug" in data + + assert_uuid(data["stream_id"]) + assert_uuid(data["agent_id"]) + assert isinstance(data["final_message"], str) + assert isinstance(data["tool_log"], list) + assert isinstance(data["agent_slug"], str) + + # Проверяем структуру tool_log + for tool_entry in data["tool_log"]: + assert "name" in tool_entry + assert "args" in tool_entry + assert "result" in tool_entry + # "error" может отсутствовать или быть None + + +@pytest.mark.asyncio +async def test_agent_stream_task_context(agent_token: str, test_task: dict): + """Test agent streaming in context of specific task""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + assert auth_data["type"] == "auth.ok" + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + + stream_id = str(uuid.uuid4()) + + # Стриминг в контексте задачи + start_event = { + "type": "agent.stream.start", + "data": { + "stream_id": stream_id, + "project_id": test_task["project"]["id"], + "chat_id": None, # Комментарий к задаче, не чат + "task_id": test_task["id"], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + assert_stream_start_structure(start_event) + + # Проверяем что task_id корректно указан + assert start_event["data"]["task_id"] == test_task["id"] + assert start_event["data"]["chat_id"] is None + + except Exception as e: + pytest.fail(f"Stream task context test failed: {e}") + + +@pytest.mark.asyncio +async def test_agent_stream_with_multiple_tools(agent_token: str, test_project: dict): + """Test agent streaming with multiple tool calls""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + stream_id = str(uuid.uuid4()) + + # Симулируем последовательность вызовов инструментов + tools_sequence = [ + { + "name": "web_search", + "args": {"query": "python best practices"}, + "result": {"results": ["doc1", "doc2"]} + }, + { + "name": "read_file", + "args": {"filename": "requirements.txt"}, + "result": {"content": "pytest==7.0.0\nhttpx==0.24.0"} + }, + { + "name": "execute_command", + "args": {"command": "pytest --version"}, + "result": {"output": "pytest 7.0.0", "exit_code": 0} + } + ] + + # Создаём события для каждого инструмента + for i, tool in enumerate(tools_sequence): + tool_event = { + "type": "agent.stream.tool", + "data": { + "stream_id": stream_id, + "tool_name": tool["name"], + "tool_args": tool["args"], + "tool_result": tool["result"], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + assert_stream_tool_structure(tool_event) + + # Финальное событие с полным tool_log + end_event = { + "type": "agent.stream.end", + "data": { + "stream_id": stream_id, + "final_message": "I've completed the multi-step analysis.", + "tool_log": [ + { + "name": tool["name"], + "args": tool["args"], + "result": tool["result"], + "error": None + } for tool in tools_sequence + ], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + assert_stream_end_structure(end_event) + assert len(end_event["data"]["tool_log"]) == 3 + + except Exception as e: + pytest.fail(f"Multiple tools stream test failed: {e}") + + +@pytest.mark.asyncio +async def test_agent_stream_with_tool_error(agent_token: str, test_project: dict): + """Test agent streaming when tool call fails""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + stream_id = str(uuid.uuid4()) + + # Инструмент с ошибкой + tool_event = { + "type": "agent.stream.tool", + "data": { + "stream_id": stream_id, + "tool_name": "execute_command", + "tool_args": {"command": "nonexistent_command"}, + "tool_result": None, # Нет результата при ошибке + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + # Финальное событие с ошибкой в tool_log + end_event = { + "type": "agent.stream.end", + "data": { + "stream_id": stream_id, + "final_message": "I encountered an error while executing the command.", + "tool_log": [ + { + "name": "execute_command", + "args": {"command": "nonexistent_command"}, + "result": None, + "error": "Command not found: nonexistent_command" + } + ], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + assert_stream_tool_structure(tool_event) + assert_stream_end_structure(end_event) + + # Проверяем что ошибка корректно записана + error_log = end_event["data"]["tool_log"][0] + assert error_log["error"] is not None + assert error_log["result"] is None + + except Exception as e: + pytest.fail(f"Tool error stream test failed: {e}") + + +@pytest.mark.asyncio +async def test_agent_stream_incremental_delta(agent_token: str, test_project: dict): + """Test agent streaming with incremental text deltas""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + stream_id = str(uuid.uuid4()) + + # Симулируем инкрементальное написание текста + text_deltas = [ + "I need to ", + "analyze this ", + "problem carefully. ", + "Let me start by ", + "examining the requirements..." + ] + + full_text = "" + + for delta in text_deltas: + full_text += delta + + delta_event = { + "type": "agent.stream.delta", + "data": { + "stream_id": stream_id, + "delta": delta, + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + assert_stream_delta_structure(delta_event) + assert delta_event["data"]["delta"] == delta + + # Финальное сообщение должно содержать полный текст + end_event = { + "type": "agent.stream.end", + "data": { + "stream_id": stream_id, + "final_message": full_text, + "tool_log": [], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + + assert_stream_end_structure(end_event) + assert end_event["data"]["final_message"] == full_text + + except Exception as e: + pytest.fail(f"Incremental delta stream test failed: {e}") + + +@pytest.mark.asyncio +async def test_agent_stream_ids_consistency(agent_token: str, test_project: dict): + """Test that stream_id is consistent across all events in one stream""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + + agent_id = auth_data["data"]["member_id"] + agent_slug = auth_data["data"]["slug"] + stream_id = str(uuid.uuid4()) + + # Все события должны иметь одинаковый stream_id + events = [ + { + "type": "agent.stream.start", + "data": { + "stream_id": stream_id, + "project_id": test_project["id"], + "chat_id": test_project["chat_id"], + "task_id": None, + "agent_id": agent_id, + "agent_slug": agent_slug + } + }, + { + "type": "agent.stream.delta", + "data": { + "stream_id": stream_id, + "delta": "Processing...", + "agent_id": agent_id, + "agent_slug": agent_slug + } + }, + { + "type": "agent.stream.tool", + "data": { + "stream_id": stream_id, + "tool_name": "test_tool", + "tool_args": {}, + "tool_result": {"status": "ok"}, + "agent_id": agent_id, + "agent_slug": agent_slug + } + }, + { + "type": "agent.stream.end", + "data": { + "stream_id": stream_id, + "final_message": "Complete", + "tool_log": [], + "agent_id": agent_id, + "agent_slug": agent_slug + } + } + ] + + # Проверяем что все события имеют одинаковый stream_id + for event in events: + assert event["data"]["stream_id"] == stream_id + assert event["data"]["agent_id"] == agent_id + assert event["data"]["agent_slug"] == agent_slug + + except Exception as e: + pytest.fail(f"Stream ID consistency test failed: {e}") + + +@pytest.mark.asyncio +async def test_agent_config_updated_event(agent_token: str): + """Test config.updated event structure""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + auth_response = await websocket.recv() + auth_data = json.loads(auth_response) + + # Симулируем событие обновления конфигурации + config_event = { + "type": "config.updated", + "data": { + "model": "gpt-4", + "provider": "openai", + "prompt": "Updated system prompt", + "chat_listen": "mentions", + "task_listen": "assigned", + "max_concurrent_tasks": 3, + "capabilities": ["coding", "testing", "documentation"], + "labels": ["backend", "python", "api", "database"] + } + } + + # Проверяем структуру события + assert config_event["type"] == "config.updated" + assert "data" in config_event + + config_data = config_event["data"] + assert "chat_listen" in config_data + assert "task_listen" in config_data + assert "capabilities" in config_data + assert "labels" in config_data + assert "max_concurrent_tasks" in config_data + + assert config_data["chat_listen"] in ["all", "mentions", "none"] + assert config_data["task_listen"] in ["all", "mentions", "assigned", "none"] + assert isinstance(config_data["capabilities"], list) + assert isinstance(config_data["labels"], list) + assert isinstance(config_data["max_concurrent_tasks"], int) + assert config_data["max_concurrent_tasks"] > 0 + + except Exception as e: + pytest.fail(f"Config updated event test failed: {e}") \ No newline at end of file diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..5fe5d1a --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,487 @@ +""" +Тесты работы с задачами - CRUD операции, статусы, назначения, этапы, зависимости +""" +import pytest +import httpx +import uuid +from conftest import assert_uuid, assert_timestamp + + +@pytest.mark.asyncio +async def test_get_tasks_list(http_client: httpx.AsyncClient): + """Test getting list of all tasks""" + response = await http_client.get("/tasks") + assert response.status_code == 200 + + tasks = response.json() + assert isinstance(tasks, list) + + +@pytest.mark.asyncio +async def test_get_tasks_by_project(http_client: httpx.AsyncClient, test_project: dict, test_task: dict): + """Test filtering tasks by project""" + response = await http_client.get(f"/tasks?project_id={test_project['id']}") + assert response.status_code == 200 + + tasks = response.json() + assert isinstance(tasks, list) + + # Все задачи должны быть из нашего проекта + for task in tasks: + assert task["project"]["id"] == test_project["id"] + + # Наша тестовая задача должна быть в списке + task_ids = [t["id"] for t in tasks] + assert test_task["id"] in task_ids + + +@pytest.mark.asyncio +async def test_get_task_by_id(http_client: httpx.AsyncClient, test_task: dict): + """Test getting specific task by ID""" + response = await http_client.get(f"/tasks/{test_task['id']}") + assert response.status_code == 200 + + task = response.json() + assert task["id"] == test_task["id"] + assert task["title"] == test_task["title"] + assert task["description"] == test_task["description"] + assert task["status"] == test_task["status"] + + # Проверяем структуру + assert "project" in task + assert "number" in task + assert "key" in task + assert "type" in task + assert "priority" in task + assert "labels" in task + assert "assignee" in task + assert "reviewer" in task + assert "subtasks" in task + assert "steps" in task + assert "watcher_ids" in task + assert "depends_on" in task + assert "position" in task + assert "created_at" in task + assert "updated_at" in task + + +@pytest.mark.asyncio +async def test_get_task_not_found(http_client: httpx.AsyncClient): + """Test getting non-existent task returns 404""" + fake_id = str(uuid.uuid4()) + response = await http_client.get(f"/tasks/{fake_id}") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_task(http_client: httpx.AsyncClient, test_project: dict): + """Test creating new task""" + task_data = { + "title": "New Test Task", + "description": "Task created via API test", + "type": "task", + "status": "backlog", + "priority": "high", + "labels": ["backend", "urgent"] + } + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + + task = response.json() + assert task["title"] == task_data["title"] + assert task["description"] == task_data["description"] + assert task["type"] == task_data["type"] + assert task["status"] == task_data["status"] + assert task["priority"] == task_data["priority"] + assert task["labels"] == task_data["labels"] + + # Проверяем автогенерируемые поля + assert_uuid(task["id"]) + assert isinstance(task["number"], int) + assert task["number"] > 0 + assert task["key"].startswith(test_project["slug"].upper()) + assert f"-{task['number']}" in task["key"] + + # Проверяем связанный проект + assert task["project"]["id"] == test_project["id"] + assert task["project"]["slug"] == test_project["slug"] + + # По умолчанию assignee и reviewer должны быть null + assert task["assignee"] is None + assert task["reviewer"] is None + + # Массивы должны быть пустыми + assert task["subtasks"] == [] + assert task["steps"] == [] + assert task["watcher_ids"] == [] + assert task["depends_on"] == [] + + +@pytest.mark.asyncio +async def test_create_task_minimal(http_client: httpx.AsyncClient, test_project: dict): + """Test creating task with minimal required data""" + task_data = {"title": "Minimal Task"} + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + + task = response.json() + assert task["title"] == "Minimal Task" + assert task["type"] == "task" # По умолчанию + assert task["status"] == "backlog" # По умолчанию + assert task["priority"] == "medium" # По умолчанию + + +@pytest.mark.asyncio +async def test_create_task_with_assignee(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test creating task with assignee""" + task_data = { + "title": "Assigned Task", + "assignee_id": test_user["id"] + } + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + + task = response.json() + assert task["assignee"]["id"] == test_user["id"] + assert task["assignee"]["slug"] == test_user["slug"] + assert task["assignee"]["name"] == test_user["name"] + + +@pytest.mark.asyncio +async def test_create_task_without_project_fails(http_client: httpx.AsyncClient): + """Test creating task without project_id fails""" + task_data = {"title": "Task without project"} + + response = await http_client.post("/tasks", json=task_data) + assert response.status_code == 422 # Missing query parameter + + +@pytest.mark.asyncio +async def test_update_task(http_client: httpx.AsyncClient, test_task: dict): + """Test updating task information""" + update_data = { + "title": "Updated Task Title", + "description": "Updated description", + "status": "in_progress", + "priority": "high", + "labels": ["updated", "test"] + } + + response = await http_client.patch(f"/tasks/{test_task['id']}", json=update_data) + assert response.status_code == 200 + + task = response.json() + assert task["title"] == "Updated Task Title" + assert task["description"] == "Updated description" + assert task["status"] == "in_progress" + assert task["priority"] == "high" + assert task["labels"] == ["updated", "test"] + assert task["id"] == test_task["id"] + + +@pytest.mark.asyncio +async def test_assign_task(http_client: httpx.AsyncClient, test_task: dict, test_user: dict): + """Test assigning task to user""" + response = await http_client.post(f"/tasks/{test_task['id']}/assign", json={ + "assignee_id": test_user["id"] + }) + assert response.status_code == 200 + + task = response.json() + assert task["assignee"]["id"] == test_user["id"] + assert task["assignee"]["slug"] == test_user["slug"] + + +@pytest.mark.asyncio +async def test_assign_task_nonexistent_user(http_client: httpx.AsyncClient, test_task: dict): + """Test assigning task to non-existent user returns 404""" + fake_user_id = str(uuid.uuid4()) + response = await http_client.post(f"/tasks/{test_task['id']}/assign", json={ + "assignee_id": fake_user_id + }) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_take_task_by_agent(agent_client: httpx.AsyncClient, test_project: dict): + """Test agent taking unassigned task""" + # Создаём неназначенную задачу + task_data = { + "title": "Task for Agent", + "status": "todo" # Подходящий статус для взятия + } + + response = await agent_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + task = response.json() + + # Берём задачу в работу + response = await agent_client.post(f"/tasks/{task['id']}/take") + assert response.status_code == 200 + + updated_task = response.json() + assert updated_task["status"] == "in_progress" + # assignee_id должен быть установлен на текущего агента + assert updated_task["assignee"] is not None + + +@pytest.mark.asyncio +async def test_reject_assigned_task(agent_client: httpx.AsyncClient, test_project: dict): + """Test agent rejecting assigned task""" + # Создаём задачу и назначаем её на агента + task_data = { + "title": "Task to Reject", + "status": "in_progress" + } + + response = await agent_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + task = response.json() + + # Отклоняем задачу + response = await agent_client.post(f"/tasks/{task['id']}/reject", json={ + "reason": "Too complex for my current capabilities" + }) + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + assert data["reason"] == "Too complex for my current capabilities" + + +@pytest.mark.asyncio +async def test_add_task_watcher(http_client: httpx.AsyncClient, test_task: dict): + """Test adding watcher to task""" + response = await http_client.post(f"/tasks/{test_task['id']}/watch") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + assert isinstance(data["watcher_ids"], list) + + +@pytest.mark.asyncio +async def test_remove_task_watcher(http_client: httpx.AsyncClient, test_task: dict): + """Test removing watcher from task""" + # Сначала добавляем себя как наблюдателя + await http_client.post(f"/tasks/{test_task['id']}/watch") + + # Затем удаляем + response = await http_client.delete(f"/tasks/{test_task['id']}/watch") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + assert isinstance(data["watcher_ids"], list) + + +@pytest.mark.asyncio +async def test_filter_tasks_by_status(http_client: httpx.AsyncClient, test_project: dict): + """Test filtering tasks by status""" + # Создаём задачи с разными статусами + statuses = ["backlog", "todo", "in_progress", "done"] + created_tasks = [] + + for status in statuses: + task_data = { + "title": f"Task {status}", + "status": status + } + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + created_tasks.append(response.json()) + + # Фильтруем по статусу "in_progress" + response = await http_client.get(f"/tasks?project_id={test_project['id']}&status=in_progress") + assert response.status_code == 200 + + tasks = response.json() + for task in tasks: + assert task["status"] == "in_progress" + + +@pytest.mark.asyncio +async def test_filter_tasks_by_assignee(http_client: httpx.AsyncClient, test_project: dict, test_user: dict): + """Test filtering tasks by assignee""" + # Создаём задачу и назначаем её на пользователя + task_data = { + "title": "Task for specific user", + "assignee_id": test_user["id"] + } + + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + task = response.json() + + # Фильтруем по исполнителю + response = await http_client.get(f"/tasks?assignee_id={test_user['id']}") + assert response.status_code == 200 + + tasks = response.json() + for task in tasks: + assert task["assignee"]["id"] == test_user["id"] + + +@pytest.mark.asyncio +async def test_search_tasks_by_number(http_client: httpx.AsyncClient, test_task: dict): + """Test searching tasks by task number""" + task_key = test_task["key"] + + response = await http_client.get(f"/tasks?q={task_key}") + assert response.status_code == 200 + + tasks = response.json() + task_keys = [t["key"] for t in tasks] + assert task_key in task_keys + + +@pytest.mark.asyncio +async def test_search_tasks_by_title(http_client: httpx.AsyncClient, test_task: dict): + """Test searching tasks by title""" + search_term = test_task["title"].split()[0] # Первое слово из заголовка + + response = await http_client.get(f"/tasks?q={search_term}") + assert response.status_code == 200 + + tasks = response.json() + # Хотя бы одна задача должна содержать искомое слово + found = any(search_term.lower() in task["title"].lower() for task in tasks) + assert found + + +@pytest.mark.asyncio +async def test_delete_task(http_client: httpx.AsyncClient, test_project: dict): + """Test deleting task""" + # Создаём временную задачу для удаления + task_data = {"title": "Task to Delete"} + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task_data) + assert response.status_code == 201 + task = response.json() + + # Удаляем задачу + response = await http_client.delete(f"/tasks/{task['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + # Проверяем что задача действительно удалена + response = await http_client.get(f"/tasks/{task['id']}") + assert response.status_code == 404 + + +# Тесты этапов (Steps) + +@pytest.mark.asyncio +async def test_get_task_steps(http_client: httpx.AsyncClient, test_task: dict): + """Test getting task steps list""" + response = await http_client.get(f"/tasks/{test_task['id']}/steps") + assert response.status_code == 200 + + steps = response.json() + assert isinstance(steps, list) + + +@pytest.mark.asyncio +async def test_create_task_step(http_client: httpx.AsyncClient, test_task: dict): + """Test creating new task step""" + step_data = {"title": "Complete step 1"} + + response = await http_client.post(f"/tasks/{test_task['id']}/steps", json=step_data) + assert response.status_code == 201 + + step = response.json() + assert step["title"] == "Complete step 1" + assert step["done"] is False # По умолчанию + assert isinstance(step["position"], int) + assert_uuid(step["id"]) + + +@pytest.mark.asyncio +async def test_update_task_step(http_client: httpx.AsyncClient, test_task: dict): + """Test updating task step""" + # Создаём этап + step_data = {"title": "Step to update"} + response = await http_client.post(f"/tasks/{test_task['id']}/steps", json=step_data) + assert response.status_code == 201 + step = response.json() + + # Обновляем этап + update_data = { + "title": "Updated step title", + "done": True + } + + response = await http_client.patch(f"/tasks/{test_task['id']}/steps/{step['id']}", json=update_data) + assert response.status_code == 200 + + updated_step = response.json() + assert updated_step["title"] == "Updated step title" + assert updated_step["done"] is True + assert updated_step["id"] == step["id"] + + +@pytest.mark.asyncio +async def test_delete_task_step(http_client: httpx.AsyncClient, test_task: dict): + """Test deleting task step""" + # Создаём этап + step_data = {"title": "Step to delete"} + response = await http_client.post(f"/tasks/{test_task['id']}/steps", json=step_data) + assert response.status_code == 201 + step = response.json() + + # Удаляем этап + response = await http_client.delete(f"/tasks/{test_task['id']}/steps/{step['id']}") + assert response.status_code == 200 + + data = response.json() + assert data["ok"] is True + + +# Тесты связей между задачами + +@pytest.mark.asyncio +async def test_create_task_link(http_client: httpx.AsyncClient, test_project: dict): + """Test creating dependency between tasks""" + # Создаём две задачи + task1_data = {"title": "Source Task"} + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task1_data) + assert response.status_code == 201 + task1 = response.json() + + task2_data = {"title": "Target Task"} + response = await http_client.post(f"/tasks?project_id={test_project['id']}", json=task2_data) + assert response.status_code == 201 + task2 = response.json() + + # Создаём связь "task1 зависит от task2" + link_data = { + "target_id": task2["id"], + "link_type": "depends_on" + } + + response = await http_client.post(f"/tasks/{task1['id']}/links", json=link_data) + assert response.status_code == 201 + + link = response.json() + assert link["source_id"] == task1["id"] + assert link["target_id"] == task2["id"] + assert link["link_type"] == "depends_on" + assert link["target_key"] == task2["key"] + assert link["source_key"] == task1["key"] + assert_uuid(link["id"]) + + +@pytest.mark.asyncio +async def test_create_task_link_self_reference_fails(http_client: httpx.AsyncClient, test_task: dict): + """Test that linking task to itself returns 400""" + link_data = { + "target_id": test_task["id"], # Ссылка на себя + "link_type": "depends_on" + } + + response = await http_client.post(f"/tasks/{test_task['id']}/links", json=link_data) + assert response.status_code == 400 \ No newline at end of file diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 0000000..81b4e94 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,411 @@ +""" +Тесты WebSocket подключения и получения событий +""" +import pytest +import asyncio +import websockets +import json +import uuid +from conftest import assert_uuid + + +@pytest.mark.asyncio +async def test_websocket_auth_with_jwt(admin_token: str): + """Test WebSocket authentication using JWT token""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ожидаем сообщение auth.ok + response = await websocket.recv() + auth_response = json.loads(response) + + assert auth_response["type"] == "auth.ok" + assert "data" in auth_response + + auth_data = auth_response["data"] + assert "member_id" in auth_data + assert "slug" in auth_data + assert "name" in auth_data + assert "lobby_chat_id" in auth_data + assert "projects" in auth_data + assert "online" in auth_data + + assert_uuid(auth_data["member_id"]) + assert auth_data["slug"] == "admin" + assert_uuid(auth_data["lobby_chat_id"]) + assert isinstance(auth_data["projects"], list) + assert isinstance(auth_data["online"], list) + + except Exception as e: + pytest.fail(f"WebSocket connection failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_auth_with_agent_token(agent_token: str): + """Test WebSocket authentication using agent token""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ожидаем сообщение auth.ok + response = await websocket.recv() + auth_response = json.loads(response) + + assert auth_response["type"] == "auth.ok" + auth_data = auth_response["data"] + + # У агента должна быть конфигурация + assert "agent_config" in auth_data + assert "assigned_tasks" in auth_data + + agent_config = auth_data["agent_config"] + assert "chat_listen" in agent_config + assert "task_listen" in agent_config + assert "capabilities" in agent_config + assert "labels" in agent_config + + assert isinstance(auth_data["assigned_tasks"], list) + + except Exception as e: + pytest.fail(f"WebSocket connection failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_auth_first_message(admin_token: str): + """Test WebSocket authentication by sending auth message first""" + uri = "ws://localhost:8100/ws" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Отправляем сообщение аутентификации + auth_message = { + "type": "auth", + "token": admin_token + } + await websocket.send(json.dumps(auth_message)) + + # Ожидаем ответ + response = await websocket.recv() + auth_response = json.loads(response) + + assert auth_response["type"] == "auth.ok" + assert "data" in auth_response + + except Exception as e: + pytest.fail(f"WebSocket auth message failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_auth_invalid_token(): + """Test WebSocket authentication with invalid token""" + uri = "ws://localhost:8100/ws?token=invalid_token" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ожидаем сообщение об ошибке + response = await websocket.recv() + auth_response = json.loads(response) + + assert auth_response["type"] == "auth.error" + assert "message" in auth_response + + except websockets.exceptions.ConnectionClosedError: + # Соединение может быть закрыто сразу при неверном токене + pass + except Exception as e: + pytest.fail(f"Unexpected error: {e}") + + +@pytest.mark.asyncio +async def test_websocket_heartbeat(admin_token: str): + """Test WebSocket heartbeat mechanism""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем heartbeat + heartbeat = {"type": "heartbeat"} + await websocket.send(json.dumps(heartbeat)) + + # Heartbeat может не иметь ответа, но соединение должно остаться живым + await asyncio.sleep(0.5) + + # Проверяем что соединение живо отправкой ещё одного heartbeat + heartbeat_with_status = { + "type": "heartbeat", + "status": "online" + } + await websocket.send(json.dumps(heartbeat_with_status)) + + except Exception as e: + pytest.fail(f"Heartbeat test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_project_subscription(admin_token: str, test_project: dict): + """Test subscribing to project events via WebSocket""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Подписываемся на проект + subscribe = { + "type": "project.subscribe", + "project_id": test_project["id"] + } + await websocket.send(json.dumps(subscribe)) + + # ACK может прийти или не прийти, это зависит от реализации + await asyncio.sleep(0.5) + + # Отписываемся от проекта + unsubscribe = { + "type": "project.unsubscribe", + "project_id": test_project["id"] + } + await websocket.send(json.dumps(unsubscribe)) + + except Exception as e: + pytest.fail(f"Project subscription test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_send_chat_message(admin_token: str, test_project: dict): + """Test sending chat message via WebSocket""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем сообщение в чат проекта + message = { + "type": "chat.send", + "chat_id": test_project["chat_id"], + "content": "Test message via WebSocket" + } + await websocket.send(json.dumps(message)) + + # Ожидаем получить обратно событие message.new + await asyncio.wait_for( + receive_message_new_event(websocket), + timeout=5.0 + ) + + except asyncio.TimeoutError: + pytest.fail("Timeout waiting for message.new event") + except Exception as e: + pytest.fail(f"Chat message test failed: {e}") + + +async def receive_message_new_event(websocket): + """Helper function to receive message.new event""" + while True: + response = await websocket.recv() + event = json.loads(response) + + if event["type"] == "message.new": + assert "data" in event + message_data = event["data"] + + assert "id" in message_data + assert "content" in message_data + assert "author" in message_data + assert "created_at" in message_data + + assert_uuid(message_data["id"]) + return event + + +@pytest.mark.asyncio +async def test_websocket_send_task_comment(admin_token: str, test_task: dict): + """Test sending task comment via WebSocket""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем комментарий к задаче + message = { + "type": "chat.send", + "task_id": test_task["id"], + "content": "Task comment via WebSocket" + } + await websocket.send(json.dumps(message)) + + # Ожидаем получить обратно событие message.new + await asyncio.wait_for( + receive_message_new_event(websocket), + timeout=5.0 + ) + + except asyncio.TimeoutError: + pytest.fail("Timeout waiting for task comment event") + except Exception as e: + pytest.fail(f"Task comment test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_agent_with_thinking(agent_token: str, test_project: dict): + """Test agent sending message with thinking via WebSocket""" + uri = f"ws://localhost:8100/ws?token={agent_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем сообщение с thinking (только агент может) + message = { + "type": "chat.send", + "chat_id": test_project["chat_id"], + "content": "Agent message with thinking", + "thinking": "Let me think about this problem..." + } + await websocket.send(json.dumps(message)) + + # Ожидаем получить обратно событие message.new + event = await asyncio.wait_for( + receive_message_new_event(websocket), + timeout=5.0 + ) + + # Проверяем что thinking присутствует + message_data = event["data"] + assert "thinking" in message_data + assert message_data["thinking"] == "Let me think about this problem..." + assert message_data["author_type"] == "agent" + + except asyncio.TimeoutError: + pytest.fail("Timeout waiting for agent message event") + except Exception as e: + pytest.fail(f"Agent message test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_message_with_mentions(admin_token: str, test_project: dict, test_user: dict): + """Test sending message with mentions via WebSocket""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем сообщение с упоминанием + message = { + "type": "chat.send", + "chat_id": test_project["chat_id"], + "content": f"Hey @{test_user['slug']}, check this!", + "mentions": [test_user["id"]] + } + await websocket.send(json.dumps(message)) + + # Ожидаем получить обратно событие message.new + event = await asyncio.wait_for( + receive_message_new_event(websocket), + timeout=5.0 + ) + + # Проверяем что mentions присутствуют + message_data = event["data"] + assert "mentions" in message_data + assert len(message_data["mentions"]) == 1 + assert message_data["mentions"][0]["id"] == test_user["id"] + + except asyncio.TimeoutError: + pytest.fail("Timeout waiting for message with mentions") + except Exception as e: + pytest.fail(f"Message with mentions test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_invalid_message_format(admin_token: str): + """Test sending invalid message format via WebSocket""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Ждём аутентификацию + await websocket.recv() # auth.ok + + # Отправляем невалидное сообщение + await websocket.send("invalid json") + + # Может прийти ошибка или соединение может закрыться + try: + response = await asyncio.wait_for(websocket.recv(), timeout=2.0) + error_event = json.loads(response) + assert error_event["type"] == "error" + except asyncio.TimeoutError: + # Таймаут тоже нормально - сервер может игнорировать невалидные сообщения + pass + + except websockets.exceptions.ConnectionClosed: + # Соединение может быть закрыто при невалидном сообщении + pass + except Exception as e: + pytest.fail(f"Invalid message test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_connection_without_auth(): + """Test WebSocket connection without authentication""" + uri = "ws://localhost:8100/ws" + + try: + async with websockets.connect(uri, timeout=10) as websocket: + # Отправляем сообщение без аутентификации + message = { + "type": "chat.send", + "content": "Unauthorized message" + } + await websocket.send(json.dumps(message)) + + # Должна прийти ошибка аутентификации + response = await asyncio.wait_for(websocket.recv(), timeout=5.0) + error_event = json.loads(response) + + assert error_event["type"] == "auth.error" or error_event["type"] == "error" + + except websockets.exceptions.ConnectionClosed: + # Соединение может быть закрыто если требуется немедленная аутентификация + pass + except Exception as e: + pytest.fail(f"Unauthenticated connection test failed: {e}") + + +@pytest.mark.asyncio +async def test_websocket_multiple_connections(admin_token: str): + """Test multiple WebSocket connections for same user""" + uri = f"ws://localhost:8100/ws?token={admin_token}" + + try: + # Открываем два соединения одновременно + async with websockets.connect(uri, timeout=10) as ws1, \ + websockets.connect(uri, timeout=10) as ws2: + + # Ждём аутентификацию на обоих соединениях + await ws1.recv() # auth.ok + await ws2.recv() # auth.ok + + # Отправляем heartbeat в оба соединения + heartbeat = {"type": "heartbeat"} + await ws1.send(json.dumps(heartbeat)) + await ws2.send(json.dumps(heartbeat)) + + # Оба соединения должны остаться живыми + await asyncio.sleep(1) + + except Exception as e: + pytest.fail(f"Multiple connections test failed: {e}") \ No newline at end of file