From 5f6a3dffe423e18cd9b2762aa67afafa1f313d5f Mon Sep 17 00:00:00 2001 From: zhi Date: Thu, 19 Mar 2026 12:43:44 +0000 Subject: [PATCH] test(P14.1): add comprehensive backend API test suite Add 134 tests as independent test project: - test_auth.py (5): Login, JWT, protected endpoints - test_users.py (8): User CRUD, permissions - test_projects.py (8): Project CRUD, ownership - test_milestones.py (7): Milestone CRUD, filtering - test_tasks.py (8): Task CRUD, filtering - test_comments.py (5): Comment CRUD, permissions - test_roles.py (9): Role/permission management - test_milestone_actions.py (17): Milestone state machine - test_task_transitions.py (34): Task state machine - test_propose.py (19): Propose CRUD, lifecycle - test_misc.py (14): Notifications, activity, API keys, dashboard Setup: - conftest.py: SQLite in-memory DB, fixtures - requirements.txt: Dependencies - pyproject.toml: Pytest config - README.md: Documentation --- README.md | 51 ++ pyproject.toml | 6 + requirements.txt | 22 + tests/__init__.py | 1 + tests/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 214 bytes .../conftest.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 11600 bytes .../test_auth.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 8042 bytes ...test_comments.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 14342 bytes ...stone_actions.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 33797 bytes ...st_milestones.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 14972 bytes .../test_misc.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 33556 bytes ...test_projects.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 13191 bytes .../test_propose.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 47070 bytes .../test_roles.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 18435 bytes ...k_transitions.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 52181 bytes .../test_tasks.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 20036 bytes .../test_users.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 12144 bytes tests/conftest.py | 304 ++++++++++ tests/test_auth.py | 59 ++ tests/test_comments.py | 180 ++++++ tests/test_milestone_actions.py | 358 +++++++++++ tests/test_milestones.py | 148 +++++ tests/test_misc.py | 264 ++++++++ tests/test_projects.py | 108 ++++ tests/test_propose.py | 559 +++++++++++++++++ tests/test_roles.py | 182 ++++++ tests/test_task_transitions.py | 564 ++++++++++++++++++ tests/test_tasks.py | 211 +++++++ tests/test_users.py | 100 ++++ 29 files changed, 3117 insertions(+) create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-312.pyc create mode 100644 tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_auth.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_comments.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_milestone_actions.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_milestones.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_misc.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_projects.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_propose.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_roles.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_task_transitions.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_users.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_comments.py create mode 100644 tests/test_milestone_actions.py create mode 100644 tests/test_milestones.py create mode 100644 tests/test_misc.py create mode 100644 tests/test_projects.py create mode 100644 tests/test_propose.py create mode 100644 tests/test_roles.py create mode 100644 tests/test_task_transitions.py create mode 100644 tests/test_tasks.py create mode 100644 tests/test_users.py diff --git a/README.md b/README.md index 67fb294..c028647 100644 --- a/README.md +++ b/README.md @@ -1 +1,52 @@ # HarborForge.Backend.Test + +Independent test suite for HarborForge.Backend. + +## Setup + +```bash +# Create virtual environment +python3 -m venv venv +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt +``` + +## Run Tests + +```bash +# Run all tests +pytest + +# Run specific test file +pytest tests/test_auth.py + +# Run with verbose output +pytest -v + +# Run with coverage (requires pytest-cov) +pytest --cov=../HarborForge.Backend/app --cov-report=html +``` + +## Test Structure + +| File | Tests | Coverage | +|------|-------|----------| +| `test_auth.py` | 5 | Login, JWT, protected endpoints | +| `test_users.py` | 8 | User CRUD, permissions | +| `test_projects.py` | 8 | Project CRUD, ownership | +| `test_milestones.py` | 7 | Milestone CRUD, filtering | +| `test_tasks.py` | 8 | Task CRUD, filtering | +| `test_comments.py` | 5 | Comment CRUD, permissions | +| `test_roles.py` | 9 | Role/permission management | +| `test_milestone_actions.py` | 17 | Milestone state machine actions | +| `test_task_transitions.py` | 34 | Task state machine transitions | +| `test_propose.py` | 19 | Propose CRUD, accept/reject/reopen | +| `test_misc.py` | 14 | Notifications, activity log, API keys, dashboard | + +**Total: 134 tests** + +## How It Works + +Tests import the backend code from `../HarborForge.Backend/` via path manipulation in `conftest.py`. Uses SQLite in-memory database for fast, isolated tests. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ec7962 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --tb=short" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f68c3ee --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +# HarborForge.Backend.Test dependencies +# Tests the HarborForge.Backend as a separate project + +# Backend dependencies (must match Backend/requirements.txt) +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +pymysql==1.1.0 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +python-multipart==0.0.6 +alembic==1.13.1 +python-dotenv==1.0.0 +httpx==0.27.0 +requests==2.31.0 + +# Test dependencies +pytest==8.0.0 +pytest-asyncio==0.23.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..65140f2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# tests package diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0e310b591b1a7785549abf5fd3840738a0a3c4f GIT binary patch literal 214 zcmX@j%ge<81Y+-ZX9@u6#~=4>*>xHBim!JqZB_?O5 z=A}R+N`SJ(`tk9Zd6^~g@p=W7zc_4i^HWN5QtgUZfv#W#;$jfvBQql-V-Yiu1pwFa BJjwt7 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4f2086f87544a5150cdf0c06a31f4913f8ce5e2a GIT binary patch literal 11600 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_auth.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_auth.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8e9b5f235a1104e2a15e44dbc5ab36d64e62412 GIT binary patch literal 8042 zcmeHMU2GKB6`t9hnVp^ev-t~z0!zZ*K)q{Y163L$ph+4~N|A~}iKm`(XXftMEMC*n zv=1Gv&)jq8+`n_~ch9-Uf6V1H0iI2N|6TQqlpy>Kndp(i!jq#=_)H+em_S65sEK0< z_D)Vpm4qlv%43Qcd`XR^62cD!lDr`h=}sbOKc+Slx;*tO;LZ$_CG zc>K)CqH8&>Q_{2_*q1HpyrB&g-?J}N>%}Wo5V&mCs-#$<7P0DX)vP(oKUeGB1kT!* zta_1Ju8+Ny<~lMWSMx@x+~Q#w6{gBbf=dlOis7 z-wD~vg0nH!e|EVPa-MuAToa8;=2w%fi!gp?*4<>4sMj8HsYs4!x05Yce>+VwBsw5Z3X^R(78OZu_GOoB0aS;B;U?@Sub;0Xy?3~ z^LcEvZdNIHIj~9}w&++T)~1_IveC>)|4Xx8ChD`N^|&Nhub-^DonZRSll5eSmk+g_ zUbD8-(YVn&)OHrMmmwRsfP&juvI%y0)>GMDN3GUk8tSIi6w~S}*yhEx8tc28_SI;n zWOKOo0@*@d^$OwI`_{bnzNn|@9jzU9TCS}&bNbfS_V?KlT4QG_wcIIMcZ#RZoehq= zlCR?VJLOo^&tR!xI?m9+;n{?*VrAW&w0spSuh^74&6G_z!piKVoE3eEn6CNo51`^% z@o8dobXIH)@7~vF9@HJ}T39HK7O^y_J0-_8VI-qs6H99sM}rTCbjO$Apl-}2{UpdC zIf&%Y(>x4oQJ1wp0%A~k*-U@SiipN;cV)tXzaJHAr0LBjDj8xHuS8eyWw zg_?cdtQpK0X~QwcE$qS1A7}ajSMoNcHucvV<2Ic%UBkNCsF`)N%N9rFw;v&DIR8i2Imtfekjh2#F^oM35GiyXTHa@9+w@AGY8|c zVV6NKhvHw3IC$6=S3XZKm#J;L<&xd7>Xn*#r3}0BlG89NR;M&TtjksnBxw0vlb*Nf zJ2t&w#mgo1$jd5Dm=*B-B=~^Fa>{|pV7HeVO@AHZ7={dIip37Ct=J2C^wP5*3ov`< zeQ;;jg})Rw&+Na~UM##lE&aK$@#AYZuRX|ad9Z!oy~+9hw;stt-_|EWvQL|q9;rg# zhNZ%e`NEDlbS<8)r#GwlZ4LZ`UMgoEm@u{$o$3n2ZV zu>Rxb&F0UqO-m07+ve2W(?6TpFjv^Uq~h;>zz56%GC`43XfU&Ze~ya$GZdf|jPc2P z^M##r>cGqgK;{ZNXFixO3@oYmyB}5r+ydi*^4BOI6ytPJ9e4uj6}rbvi=gywF_S2) z5;MsxW>R>}BzaQTm`Nt7w&Ka2!u&|eOZAMIREU)zM!_~6KSH#Z=5?j$a9 zt%rx=kysBo9$S%27&ECPOLCqXda{4di|4_M$?yj{Z|3zvYum{%+K=23g!q9w8yOrb zQFP-JV`r)&$pG=wSEyaTfbtY0&WFE%-9ZtY6weoS;L-J1dIia5Ag5PYqj(KY&AEDs$_&`Ti6C)pG}qCOQgJtl%vt zdibs9_YA?$K`Xo85NX*BC+Q`-qE&($j_gUE%+RWMN>8-HeTdy~piK;|FYktvTr*m^ z|Kyh&PHD zUo3|!!>3la!JS@YF-Xd+HyKhHd=akn7?RcDN{@q#o?eN+)uD$sRt~*qkyp3t(ZpGJ z)E#*%rSNcYJ_T}Jh~sVlSLy#5yv6eX9B;o}6>pJ++X*kx6=jeDfqX>LlNib%+t3qb zNkErKSeqEi{;GM%c-`55~UhzsVR=$Wex5sotHdha7ihBo)F{BPs;!S_s$7n&HaRT9GvXt{R4~ z@RtUiE32mcAmv9gELoL<1R*9SRy@!H1lG)Ic`v^PtL4FJS!~m>S`S_1qM4K2OS3ND zn?0?|CBeG69y9G&E&m^~8YXP>m^35>ZWX*Ata$zTK=CIJ^nF-O}Y%7i2%(Z$yOD9PB#T?TnhNG67P zPRKi!szhtkK^!H?GKLf{Wh~i9G4h0t2G`aRx@b)LX>C16Phux8ax0|=u*Hv%uwe@^ zf6Onbeg-qTMxYBe>D8~TEWJk$W2Z-eI2fHrt~-1O9{jb{Jb3874=lgYKF56r!gzaj zK7v@4rPMHCg;6$h;j`Di8d)eGn{F)>j(_XEd}R`C{U>kTY|W{=Zk_$|+~?<@Fx|R! zZoaTugY5g#ONP8D4-6Fu8V$Wef*rMt(i|%h5S?5VMA( z^M%1B6@T}`ihx^STu|l|+RTjb?@@6PYDc*YFF-ZBy`R?8zG|?fv|;#~VNBX&s%Aks zYZyPBGHbYVflC<%u`7l_-^5LG0?9i_P9ga*kXeEC$F>cXuoekk0G;hXZU}#pUVkiQ zQ)~d2z@Na&SEFP)%h~u_j3E793Ub(3az@9 zpXOD5Dy(A}A72TY1d}-nopvVpRDCm8=TSNaA7DQU&Nd*AlA5%AW_zyBdK8fBP&$Ab3QRp$2Nz`V{7 zW`rRYV$E4btn#NVZy&W;DQnU(F$1Ne{e!*z ziGTa)-zSEO`8-z;_{83$2NMFv3w*D~GgN$uqx>GvLy2cHypR~xWKI=nLP+zcp<#$} zX@S$4pyGImtgP72<+v3R>QspmNAnq;&lC&1=O!-g$f&YFg<90Y?OtGBXE3PZ5LJ1N)wdqdZ`&oF0nal7)WEqCevDvS?xuf z86NfcEO~3CC2y;=%4J*5o_AEdd1s|9@2a%Rb@m*acUOEC2Ig-ejx|=$nhhx98tJ@K zo2}v(t=XWlP2olVMfDIY3`m4mOKAI-=l7DeG~hSe>l%7#Gh3_Jo{g-YeV)AtXM|5B zu*MDoceMi_af^YCuXWy|Yp?_K?;}lLuy-e{KBnx%NW~7ZbIiK&9f+1}+}K~?NwfK_ z_!UE1%-0Jot#3JCM-H(`n|+A3i&B!*9KffI>{5&k#Aw=}uz z{Tf&MB<*74l!f@P&nfFg_2-TFg&s{N3nSG(+MRoe;X4fDZ>^VO7%`}=o*E?|Tu-uK zf%$8zHj@o$qc)HWYjPy;rmYM&wGt&Aq*IJ$EOMz!=+jnW7-_p!SNjyZw2`#WpIJ!o z$CgS6{zX9N!R$ayTH`c5Rbs+Utxk+-BUHLcs1g^u#khQ0VKL1AuVEGDrxk|N3S%F` zX+<`CT4BTZCZ{C~YOB=NNR2&APGWTrIK2poZg@q;TVb-InzkC=A6>7of~aAiCaV^^ zwf!}ai)(VE>rK0SV-h4ry2XTge>QFS{%q1l(mvJu(+&40T!~KwlkswR^(#ItzvO!< z;)Xe^YqVGp;EgZ21Q-#!y3-TFco93+BzN5L^r=hX`|h~->8au za0laDngAlYc@;Hcxnq6ou{le5;DIfrsU1n)b4Fznk0p@Xp5%L#4W&klg!5Dqk0}j1 zle}c79AARP*+34-4kSD8w5EBUqk`OjH;6&y?jtwx0FsvSb{us;n+TZNL|d?_KglO+ zv+Tm|Ij$ht0g$DWc8b5HIAA}L0VKQ1#`zvr=6g69m8_toK)O`w&BMZTe6Ox}>ViQi ziz9gr?Wip3!KNl8&Dh>)< z$u`CbQjlIMxi6vEO}no8}dQ4mVO`BNC6l%|~249%&N6PcWlDe#grR|E-O3Z?k* z;zW+fqN%Z5@nkxeDx~wAlJd~!_=>>&6yE4ETOjw21JrQX#7ZLlrAAHKamW&V&H zT3|O%d(Is=KYqg-`o)=P`wd_BYrWI18?67_@au>E?$E6LUDrFV3!zKVx#$wR^FP?3 z-?RG`*nPiaANnj1o85o;`S(ZO8@cLTKJengffttoKb&@7XFabvXPnbhOKcc<_l)~& z(`R1)bzkg4&!w$%TR(c_pCA3FN3Vyv-gdp=y5PCwo%4S5;PSrX3;T}$kHgmHnYMq< zGVUgM5aDdIKE@xNOdvY@?JM>VT_3ov4t}!x?D?SQO8BE>HS)~DiFEbksl^jx)8$3)__Y1H z*T3wIFL>iscJu52kg7L6JFwv0yv*X?HMK%1=@d09D5b?3yCe%M)t};Uy1dSSG|c$w z)XWqf$Zx&AbA!K_sk2@-iEPziUcSDd=>8jh*F{(qfI(n?Ge^=LYp= zja|Z_lv)rtzgHL68IZUQH-|B|OMm%SZhOt*CqE9l9mu16v5+=5>$g z)3A$di+0pCLs7%Ksx9hVV}4(DbysFhThwH&V2gIH9%A05+oEVqo9$qwHNEZ*z>+hu zwh3##C$QJtqN$AyEuOa=q=__VEOqu=i>~niSb-+ib)|!}RyxrJl`RCY`oNykt<-Gy zY8?=-7z7KU4f_}@gmz(*CY!|{`npFuH`$)6jc;Osh`Kcoc)TWw@2jCy;Gg#wMF({ugmm4r z+pYUr8s2VQ5^i@_gHx&9+Sp&{)+(pcIn|NuE(cfogA>Jq!G@&p=3=NDi5Cg}xKi{? zQv5Nb_>HD`X(@WKDE=gsJCBWL_yqicEecpm^E1y`=`hOR56jJukwcdaeEzknBP7Z6K1pROAJUUVr)> zBu^mOi)0^?Atd{eJc;BfB&eQboE|{_Ad*8!o<{NvlEX-jAbA$aQ6%3*@;xND33?2P zZtvBv^EkGiKq4#r1>{DM`~b;|NPdW<-X_>i{|2g!@P8-$5!CQ_=nYH&dJ?<(WuQGO z2OcymUQVor;wvTaEchLNE*AgNfj9!_Gc#wdw}#FK-;TTyIo~`xd}05k;kn@pk6b=j zZGQZ^FItW5thVpE5s1&)7XuG`?qb@aw;8((?mbLf;Hv}FfUVCRocT$W-BZ19576nK zz~_Q@&oYaD*VGE7w1_2z{Zy7$cq0W79P#M|Z=%X>o8ABJ@H@l6RK1DW;RWxuWfuRg zsTE3TQ7$RGPUX&e`|N&&U4kKpbs3$3YD(bln1k_OJH81LX)oz-f<#%kC-sjpNVG@`Rld@CSoH zVbFPJ7GH05kEW*zfMC)p1Lhd@AU1D6-Ph!LRNoab}K2`n1KtJ7;@Kh)nEBn>VgyLjDgON&T!vITL zK!s-5RAQf{nXybEoujD78dQa#f#wbDQxL0HnTEg|a@2vI!Vaj~1{jr&qf7?LX(R|h zli>~H#@*Nl9qgtE(osqaqvKxyLT>sCLP&YFI*!CRjqF$50HCCK+$aJ`T0rt6AUs~= z1^_aVFha-)IaOsn2)R-MK{9{K@-=kle&!3$ZoN$2AA4`?YT%RL$H67vcc)$GrA6=Y zSA4w7mrTE^>Bz0f+)LP z4}z?e;9+e3?AH~7Y`ZMHKl$F|)%!o``M77v_r0|tNM)wd7=q}?!@;yc#8eAsf4Lqn zh%&DO4*ts&91zRAp4KTR65EK)P=m?DwbbGkD7nNIGh5v}{2?|-Ena6lQ!hfJ@ra!` za+s>z3aQFoNK3X8r<|(X2C2&Jkg5#n$x!E#Qc%i)JYlW6J@Lkkt5R0-Gs-5^$FI2MzFz5aHO~5qd?$uo(4LPtX4S9VZ z2fUQ!pyk^?LHlNO0ZMo!3+t9Hn#rW^3b zl)cAsgxZ*<;6;ugx(;A+7uh8HVKT45tMf~OG8Es{cja+O#=aeDb`GY#ua;&#fu_8cfRn<7$-C7M|U`)V6Kfaihl7 zC($|HW#3^hY`L^;Zrc*OtCn21vi_bX*Tvpu-(W99F2(2KA3eG}bYfxX#Jb6K;Ee%4 z!$jJ3laRX(*}{623+j r$LS0d;W8^B?$2!&i{+12)?)vYkFi)@VSe87C+C>e(sPSJDi82~cH5aO literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_milestone_actions.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_milestone_actions.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..593862913952214327149645f9f665ce1a16a99e GIT binary patch literal 33797 zcmeHQdvF`ac?Up%IJ{qcld_?QDM7aQ(94P~%hrREEL)T!%WOcLy z-MifbxOh;aJyEI~^26@lw|k4-!+pQ++i$=7PoK|ifurevew6x`4HnBUF=HHdJu&}1 zNW5i{Ek`V}RkjUTkJ!j(-LQSYX0;4Ej?`P}8RrovIU^mB;InSXHS9j(Cg*DZ4WB^z!3h>w&xp-f_Bep2RwGB+y=kTMUHd0APIl=+~{&&opMwpd_n zztv(n*t?;tH~L?%eJ8p5*hq%_K6(b;F5)6Di0g1~RGiNHhZ{)h@StXZoe2 zqCVnY7d@sVlM~5DqUzYdKvGp7iVmk#H8pZPsw9V!2{jqbB-E2oIyRI^jSeM?ih=u- zk_KM9)|d*dK|;x}#-b0>f}|WzWFCsj$x$^rlFmejlaMQ^IWUw~p&Q4P^l&sinjGn- z9qS$&k(0{tH1w#rN_5RYdKfyCNy^=%t{j8wC&n`Ab;gNDiaHzBi^(u3spH3C0N~3S z!(~*G-M_-=>W^6q0XCS=knaaY?!zz|G&ewJl~R}ka?G!RkGCvI%MmNwMsWKasgrH5 zTaMV}I-m~O4zwO`izCkQ`j`{ig9+J9reI>@!2{16j&_eK>A~bcM(uuOG$m)dSr4IL z7z^l3s`~g0klR|nx)VO@;D7$ZdhCVf{4(US?yM*4wOO<}*?QS_N$+VEZbr?n`3Ci> z=8wyDj01x8O!5P<|JD`s)&1a0>W%iTK2DRXz@t84PC>xSJ*{~MU z!YARz$wst@`qK=~A~^;nBgH>uuNFCGm3>&}nC+6@rtHsn44y%+k>a11^yEtxwb9hR zpK}-~Eok&!F>?lu^Zb)@j#%OPyw@V$kkM)tzOcb32i~rmfV()`V5$-3S2t2Ht_?<- zf3jAB6Hq9Jw1$+GwAPquHn>dfwispnQ)@KZFh0Kyzv!2(*%16kV9tVr?VQ_48RIO6 zTcIf1Bu8LQo3sd--Da&>9Wq^2JiE;>yA4=}&#tMCXl$E|9#-Lt7<_W$+cq*9Epn6G zthMN4(^~V`v>GiLpZeG|!PqpzH?|EfGe(4eY5{}WYJuO-ef*4(%7!vgBR#mnNb%2X z8{GA6?3ePf>HCVu5`QTzSci|L+`44H6!v|p{!$nx{8Cy?y%x!WD^MbB;X2L}$pj!0^;iE6H|DhUmhG{}rmYhj9#}IvzCNb9U(plM z$D)|(iK$%_9K{FHa?+iRK1NS$im3&=l2k{h>k4(ihh#mHO}B#XZ3$IPDjCwk!@vl| z4;SibJB4}*jNqbSyh3kGHQL-5Q{ig@b1#yOxBRANHq&M{L$!TX`^vis)#z7(g#MM_ zDqXt|_4OU;b7u>-VO2r-Ic-<)aj0}4c>u|3AVodcLVK_!7B1Lemh7WxHB<1I2B+YD zVssn7$9E_()BK+s&U52`@nPJ)qFPbg9RdibRa^>iF!9R<6Z96DC09~ep{M=}MQe6-*iPMl1} z$6y`{UXs*tUBORs4A$w4ctaN2)FeP&Mz@kqXS%ykFBlj~oa#Q6R!*v;iGd_ft&@{4 zC5M1R>E4@Aj;58}Y2|p*obE!bT~x3mF>o?DBGVH@HR>k!ADf)G4z{~S#|sg1ivIZG zy0$cNAGS&Hdzaumf(>;I=zp(SZb+7ukL9KNC*5cEo=toZXqkH8T42qj{e$p*7wzvk zr=8crJ(JE4q{x{)Z|(c`zA5_!=XvMFmiOAH+pkHRKJc}l-Sf`AbNeXojIU?X_EEjX z6~69`yt(i6zO%M>9OoQoGw+-_cj|i6$}6$oIO<&P8*WRm>CIP8zjEE*a<=)MwsUP~ zy;DzL3ICw+`;AweSH7C_?)jkM{we!R!vi-QmSFq5r7q~6v_n^e4Re9}^MU(wQt#9T zAi2Q(QycPu&N-0Sh^4?0~v(dk^E)ww|1s51~})&n`aSo^wcHRLGK z1D%>zs4odMH_a{LEz#!|(H_(1d}TB*o49wNU)6~}0r$^u%D;K`5{Qon1$HzGXg)IxwKLI2Z&COqk?8Ks%^DFI5;{6_Dq;z`B{sLW;R zwE)R+)Ln&->3yg14)rd#B&95HEYwa!O5ZLI;dHJlUpZ_G9Us&434@X@2- zI4B_`sBtTJpk$fJ>vp~!U>S5&&7JmVY2`;8E zt+!XjIVq2&l;^NUyBVsCqyah;Q*RtW=tO53NK?XxW2L?hHwA$_yMAraU=C*j|$Q9`pbBump* zX$ypnq@OaC>(yxqqtL$cJj{mJ6zm35XOSt`T?8A@Fl-l#ToY_SW3eO_zv8-LAW{Mw zgvMuadt97DPwLC`L~N!Of)m*$*Il-AGw`R=Y8i;*5ceN|Oc?_=oN?;LWv?NQF0(bH zYyiw@B8U@|UD=Qp)ItQ}gtc&0h~oyMvQ_qA9WEec?~*|rW(cbaaTq5+9B&m-$)HOW zJ|?1NpAK;%vR@8p5gp<*)C_SLC@^M2hd4nHrC~7Y2eXYZCzT*hh(H{e6CuQDg8Q+F zL7Zl-QELW>)1tLhg*aiY1!gdUHTVox8JAii2{X=DX^%mdDtyhx=*bPtVAv=(F@s@S z&GW%bk4B67d^Ey*wBVe;e^a&@AWpLmaXj$ARB)w4@gWf)9_34TLV$RbFXIUUbHZRI zg0;G&98JCqrh=jIXs_}Do_!I??;%MbIf`Tei40`Ay$W#HiJVJ}9+czQC_@d(3COBw zvnvl4q7*HxFrx)y9k>WHs1xhTfCe!;q9gU6J_YRX3n_GF^;>Jdz4q+UcarClQ-?1+ zcmBEeeYwLg%t|kQQ0e4dggRE{q+L^)3*+a<-+krEmi+3ikk75&I=6a9e)SGW1D#pD z3kw4)bC3w^nv?Kv)+nLb8Iq;xtF#5eM$$V><$85m!YH&~0d-shd35)qjtp4t=pmA? zu$YckU^ue4OuiJTq)xVj=>iZN9hc z3^tVD2VO@s)*L@n=HvLGG9SkemH9Y+Xk_>S7qpE9$2QY~BX3DGAGPC$7K$IHtx6OI zUBPDW97Y5fj986J9IGp1h**FcFX$4~&ZiGN)3@t3Eg4D@`+pKhZBU@a3f0L)Bitx%_zR_y;cFt?>Yzoq}0@4%$3qWHgyBz!a{ZJ*k8;mPw)zWdY_DZlD- zkk769+}x_i^Q#_*G|-t<+p#bZ%|RlteNMu^S)+tzXGoT&uhJF>8%aNID%Y#i5=NnY zBH{aEQbCH7HPv{$;EuRoHHJm(*yCi@l7i~^aq zkfsQrRKTsrLI^%g$m-wgmJjUPZrWQM8*Xk4IQp$OSA-pX*7-Fd#||s4h;IItp%W{a~@v0%TX zGZVp&hS-urW8QmK3#c;I{+NTTLn~W>kvxxJ1v))EoXRMVLP@NSsu{#IiH1fM`wDQM z!g^Q}Cw_-!|!O5>+11o{NX8EvT(8;e^ME--C4OVmX*(S58Lpm5fcF4A}Fs`mFjH4@EE{&Uk_(~VT zIQ*HE9023$OJLkjGwkjfbeFJ07GNa8IH?52xiY9KlVeb@er4Rf1jh091%R&z#&OPy zfRC(nRp#X|t}-8oamGql#55ivfD;%*GeF5Oiy1e`&?OlC$x+YUKQ?V?@2id2*2X z8HQvqOzGp(nyGbs%B@^cKSNjzgVnz#tx;e7J7*eg(RXejtACAHWAWcvZd$UtpTT8N zVk(a$TBhNTtRb4Tu| zxu>nBHU&>eKB~r320S(^-YW}hK+iiW*U^}trM!$OQ1{9AGX<)n=6kEd=%?{nWDswM zyQh=i6-EjqlZ8sAF)-1REMN5Kbzfu@tDbZRbTYq zfNS$|B9n;O$U;84ux^+zyRd@Gtb+1z+B(sH=PsxLgoxpgKE3g_?Es&B**Pl4Ab@ zg6^sP0sCBV^Y5a(3I(xcpWXVxSFwAMn6G6A*G?9meF@rC=KyjqwL8Lf$vY?AmzVB4t4x81US~x!!mBy&?%#Hg%1Jvo zSDx8>`qiAYZmI*wmOgnzSo0nIU* zhhp&6M2;DryoSt-hbEv!bkNi}jU;fwRZ3VP3yq-zY4&tBC#{)+)s{7rS@;O7nUnBu zmgZ@O(L9twXOLlrC$AwdExn3uF?N;!jtVMu$M1xP$D-C+%6L$SeMd~I(1KMCv8sKX zxy-_5NPNh`0e%*iK@UxvDJc7aB4wYc$(&0rv(*yTY#gBGaYmLxZ;%CRHcsO@WJ94q z2?yT;sboXpcQYZQ(Ln)xm0e^*p}S;5p(lf~pBzHvS3gITecryvw$9)&fNstyQimBQ zKvz!{UIvz`@Ns3-YiuZ#eX`%QA&{xqrj`)ilpNJI1j+$WMuVdDIzwTXgG5<}O`bwk zsu|W0n+;y@^=Q&sK(TGrTEXXokDF))Ls}~+^JoBQ;cAnqj%cj8GSAx<@iC>bN_%WA zrV3w+p&-Z+UFB$#8{|f&&bDW2oe!qG7%l252O0-j;XeZZ&DjZ@V5pJ5br=@1h=$P(EI{cE$90Y3z^n!><13^_3PfG^4RdG zsw@f`azKbHII)hU0etA0I~s>AT`~L~c(>NzmMU~C!9~eRFprv`n@uqF zW@5Pz>X}*o2ri)h+a0bY!48^mbjo?becpX>*L!=W_x$+4+}4Bntp~4#K0jIi3tzjj zyT<;G^PKa;lT!@4XdZJTPi z(0ac0-S&%L%r6IIwJo=N^W5^S`Q_kQvJL3W@@|zIG&h4en!HM@ z6DE@0YAV+&(-KCZaSCsk_79WG_PxAHT}kfpeJ?e`8{8Koa{8zfgBySe6`;j}eK9N; zNf!6T2;fjQAUhZAi(#Ng==33xeKD?*eKGDObNXPmxT;Pcj1vHa+vI{FwCNjSqY57b zC9;R@i}6DI1->t)R;LdpN{uV)0?XJJ<0t!KDmi@w2!OzSG5CBfIV$dpsXT*0Vi|`h z2}S#2cpcIBF&CC9@8gV0ywv77cfNApd0lOuMsr`hrN{Q7`nm+q;7 zH3s0H9mbmUJVeiewSRMLq@-Q6{yZpBplwO#pkSp0N(CwrlnS0%$LkFB8KW^>(mQy> zvxr{Up=E)R3#9_(>9~zu4-kCKC*p^)6N6qY)(?e5yDIooBdJUZVjY0y0kOBl zn}6Pbv1faRF%H!2ZvJtKeHFx*WT`j*XfCs>ZJA9q6?O-UO+09N(eV-UrjYApXUYm& zYq-g$)~z+w;x_%i+gf8Xy9s`wVDu9FLJedj`|;6Kv^9rIb%u^bzr-4RDk>o?YNBbB zLtsQ})cq;I$DnH5CO04_G{Ha}lL?0RRPf~D1|`!-iTJn)E24VXB2fT;1bA7uUjMhO^BTVchv)#L@hdnwRB zWco`1V;Y#Xc!aZN6D|}uE*nqR8X}yr?*sY-?}=#S87Jry%~g20p3qWrPt8%71jbxG zs>XcXR@|nRDm`a7SZ-yAx=n6ph}!H%&Bsf0f0c8irrY9L!4%gaMAD|kXDZz46$m|< z9+?QjHu50|Fqu@MBM=%VDZ}D0J~oKqtmf5e2+IRvRu-*H!?NxYghQe}*p=3i!;9N* zUgOHIl7_K}RohLr3kR$Z$!;WPkUWWGFOq#oo*&gbx^Fm12pu~Kp#zb3?uF2Sm^Pn%2pyul6apEHoi$PgAO8p3I`{B#?f4iX zr^1NgQDM`$v&7rDT3j88cy+l-yt>TxyE~KjY%5q*dCxeB{gBH#5jJ4*zKV2Z_Ah2X zjL2v|6AzY`F$Au6Tbqzs`au&%L5^j5X6SGME5&qRb077g}KX-Wk@JnC&(ww6s@94-aTX$V* zo|8KAQU^((s}@PD$V)43IPE?4^A>x(^CNe}v0>f=sT-KOvDQ-WHMi15>MYMo%ULTo z9Cf`=xeh8ze#h#0myx1f#Euo&x!8^*RDp`^Tnl4>k2_-Zg&^LA+aYB6PCbHJ1^;dBDS!4&8oS@edksnvV$i;dupbd%h1b!!zHp#W<9165J<7CSMz9I zq8a-%pZYNmcplqYM2tBmnlY0#B6}tOJ|HPMr(ms#a}rmb z%DgNp9|Uq?R-7t5Z8cqAU|Hp(YRmv=#ua<9um<$pTe%M3Wd#9aXhdT_2^izoEiZu# z+cjZoDQdn)XqxYMGWrXa7u1S9@bAld$AhuZM9;320@5iu28LatB}LQ6q8SAuwZanu z!Uxm09dMtOE(Ng|L{V$6z(fPOq4A#YRi3U>(AuEjEv-a>U|3^}6${amiMKas#rrEe zJLB6PSS;f*!P(dozJg-h9}5+hCq_rR5~HauB|QehSM6etHY5D1avpoS4hcRms2Bma z0E_ZAl1Gt5k@O(xMY0~r1|%DiY(m1Y%5;!o6|yt(2|TqO$qpntk&x>ZYsLkyIZ7J! zi+L7#`;i<*@&zC&V#QKyLSadaz>?z01Ro@Gv+-kK)mKkUJW?tcs3u%X-H)*oO(2V2 zv-}49QVv;vAq>b?98VUP*$(Bm9J=Ov26j<5M-9K1(x|c@MwZP*R_7zDf9YPRrIN>TKtdNCrBD=*RG6;9nJrQZKa1p7$u0t&F7bBJ> z2vf?%vcS~IuUSUJ)Z@;3j1+mOI1%`P3%|W>Ns_=5L?60|}t)?~wJzXedK`dA7@xmI= zbARPJCb1k~VmSye`QT!CxiMd+mJpSf973^-FZnRUvLzPoCsJ0Kf~q1}s{AFKC^+Dq zNn=TLpF9Q`=N*F&DcX zG3Obb?!jlQvMnmsHzz)cxD}Ox(m+c6ISyNNaN6i|oey-HN6)LD4ECUtRo^ zLt2Bs9<$xj-z!o>i6ya!g19?Drh%=S&r=9SHc|!`l7l*iL{d zW5oUE%DwlHp(j&I0vx3=PbmXEn6?}?XTz`ptA~f=w$_sH(zn7a%m#ZLRSC86Wf)Z& zYGXO13QpZ$<}i7E282`OuYrRFBDC7V3))KJZ%liM@w8BP9G2y}vAr4%rNqxFR_#3( z#ALb)o8?|cx3{vZlnWAXa!8$Wn)ss2YTN z;`D^@ZC$yCpUippnS)ETiC)+BS*!=cn=;t@xmYxbv#ESw4Zf%gVj;kby4JvF(TlpE z!1SUn2!VjF@WK~$>BZ(3bzPHs2v`um2Les}c1t$+(Q{PFOB_g;jh zBTdsoUL;bPW6!7kA}jz#l|*WT)950Tga`v6a4-*nNUg;sJxE;ANeyn5uJYaa zCcMhdPaJlHnM*ou!s9OKwTjfrF6ki*#&s7dNM&T4slu};=U(^} zs#X6DmLYi@CkYqpQLBF9rak27o$m}e)>DNVIbvb5p|2nrug4|p z--XsV^~~y`FF}KM*>)+(Q%WiWFUMs?_+nd9MnT~tc-3=23Jtg}53!z;L#8EpvH)KQ zm`>qK!>Dlxjcatn;G38tRsr%Qip>SLq^&f9^fu*Rfe)X`D*j$?)LE_8k8Dn>{ga@@ zYJJ`E4bLwvzMoloerDrKl!3Myfgp+ literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_milestones.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_milestones.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b79d1c142994d74c500e31b4ad6a78bccd4c3f4 GIT binary patch literal 14972 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 HAl>=@(7GqT literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_projects.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_projects.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..54daa9c9f8c645dbab7ab3777233ac30ef2ec634 GIT binary patch literal 13191 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_propose.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_propose.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b39cff1762081ef702ff6a6be4829b3c0986a9c3 GIT binary patch literal 47070 zcmeHwYj7J!dLRJeF$74000F-Ag5H82q)6&T`H__^$+9hR60oK4l$Iaigl z#k+Ntf8Fzrc5w;U(Ac28Zo2n^^mjP4vFypw~R^CwV@|tK)f%B(EOw z8hBoS>?TUI6lfJgdK81kBVUU ziyW0wBPlr^IT;%`8y^-U>A0MhJG|aUQ|IE6yw|%e^5|1v+rKw5AjMVRN^x>=yo{WNisi__nb`1YY$-hz7YVu)Cw1VRN7AVX9smlqg z5E~v&r6VVyY4LPqC^kGAOD4wvD=sF|P@9~V5(DW(YFLhJN)0E+NF}U4WP%ktnT$uF z1oX>LLY4s+niC&5D@RVHB&|z2X}e%Y8u1a1lfI~3^&esP_cXZ?$nqY89-&97SL8-kfjSi~B!V-#IRhfBjO|rQ??l z=X|Sk&fe?C0L%fdZ2$q=;C&(699DDL7PlR@!{-7%6US>r$4j>3PO%1{OLPKsi!Okk zF?ZCX`tc5q(oVydD6{sFn4B2UUZqGUd6!@ZFB3reHgyaL+SPD}A1U(Ceh%Gy*`~Oj zg`=#Ht<8EJHl^k*4K?e7Bd6jU)Q*ii(f+oBeE_m`fag}~%<#-hD_%3)x3EpH*=>q_ zP)pMs3ez|;$Yy@6x3nG=Yec8$dcy&&@+dx*`?j0apRHHw2XTOvY}p3Ull3bNivKJ; zl5C^WDE}y3%a8`WET!*7L1{c?7i+Q1DaTt*dPel7afpy@uz{uYJ?r0dHaTplzfs3v zDW#sZUNT|^*?E1hV-DNl`g*I)a2r^y7I=P!C;Hx~$v}@~17e+6uLKfyauiI5Sqls` zHM2Z@uLN05Z2uh?>lf_V2KYBZpVtqz>DVmA`YGF_G>Hv}kPV47@LWPlBY8q$B`hZm zS2lk_VR%A*ETcamLm9LF39(jM;59NlvGEND>5*nJAO@9Yt$$jo?w=M`6WeS36N3H; z!yPw?P1^m3zNGgbruVOe2HV;A(D&J}*qjCSM+qx@%w}7aRyi%USUkgOpLLi$8H@V( z^)2vPS$$&b8_p$qo5*N^5#xt@ZxY+I5rbbc82zKRY&GvoyMph_vM=`gGG>b|?^B&e zpZe+((+*Wgod<>xIhZ<)?;tE?rm-)6K5|gw5g!-hu}t05siAm8jHiL)lcA(5ok%C+ zs!xo|15#oHIWov~4$7%v)pI5u6M>ig=zSQ6)Ar9C4?Q$(&-AR{G&0s5mA%hviO53{ zOl^0RYyWL-cmv) zw+y!wZGdy4>V#&d3EQRm44tTYzcMmLShbj>2Konphwhi+Bho->Xe1`Z)jBxce{wXD z1UW%g-N_VS$ZBi9d?q!T6bWko>166;EZILC8;Yy7{c`M79BWV;_K~iK=Y2quQj*%# z4_x&SaPaX9Bgxos3`c^5A0pMO-+Hu7b;|MNDb+oYOn`KvI>eJ|?NID&ynhrPq*_Ok z+7MLR<#-$#kpfAhKQ=7(>x{gt`UvJweCQ+;^MO!vrk{>B)f*Fs62qjJDh!>Lv=p$t zH02~$@@N;4BG^P9_5*#aUB-hQ#)BSuKyP!G@nDbY1c5AOMBZ*Z=r$tnG#>0WqV1CL zdjyQU`6kt2Q=7=+q(UL%xif1^7~T#Oq_t35J_M4GavX{ zUw(ev`9W~yl=GTr+Vjq-nc#tO&j&)&rN=Hm@#+(k&a0j)o~hPr%chsj2s=OYhhKUA z;`6_#Yn^C$v;FnH<&Z{2DIJ0r+!akA9sklj)qW1?qvlf6J_ZznfYHJt~kRn!X_b zK1~|ojIc#Q#=S^tLY^9>lDS(Thb&HNdfU#Z{yir%ImDWTy+mSiGKuMqB{GNTvZ$RY z&Y*+YR;|2O(rP!COHs}<%Dq~}rgEN-U3CyYL?v5UInU1!2Ti0mCch9lPp{uh;xNj2 zI%bH~uJ23ayvn$`oL3o-^Q>j%Jmfmamey-(WqJC(3OUb=dk00M()5;AP1c`Y!D=*c z^C7VT_}7pUCX!#X(tOD-uQasA{HZl-vR_!2{lvy}wNI_hP`}AjOD|{bWN)=(EMfBW z=9d6zy%FT~YFiYp+#-E+fWAO~QYJ0YF^RsQGHw&QKZ`o~II_UgWvvAnzLb$8$x~4b_qL-aN4TlXqSY13naSX z#4<>J^d6qYF)FP>5J9jS!FmM9;eLcF(x^Ri5?Ntjo=drCkOml+Oiuy3T$#Z}kpoz3 z^di`c-~j{=B6tYF!w7x@0lv^u3_!F^+K1^!5Il-tKY{}Y4kCCA!65{X zBX|PAlL!tYID#O8pbx=U5gbMEn+Tpl@HGU_05I~Ey~cw*loXtL<0X3!$yoAA9H6 z_1}2^$=ox~77qW`xNk;y?v~JCjf9MB=3?fJt&``kX0Buk?cIO*??Yg5bR-dB5b!n#*`UwZ&{zb{{Upn#0?y2&m8$T)Z9{p)50{QX?Zphq0$u^2rn z=t+M1hOq9^4t|;?XCSwi<|4uaJPDWN>B%mdEP>Fl`Me<4ps#d(*?2*sJvZUj-hx*E zH_=7(P7R>LM~=Bd}U zmND=L-gJO9deDUGv->1monF5gjpg(ZsIiV|rXS~+gc?`Iajve3Xz!ae z>KRJogCd)uCKLWl;q;o-s8P>GpqfGGbN^ta@!%liAq<-PAn0dmKZ1(2>Kg#6ADH<9*D}+Y z!kWGBZhUXYyQ}iYvc$9=cLk=gM2e0Y5!ym88z4{saymeAqrdkgIk zzwgT(d!}&cn@Ea}-xX5KR7exMiFU@IHOVin)&#!{L~HWMmriT)>!OsRTl9#+JbO3S z-hh3z9L20%AjR|&is>t%n00rVV%AxF?H5il>ot2f*qy<4e77j3dw~?wODJaJ=S(pJ zoMINs;!8v^Ex3u0#!Yy>jC}*#MA*Phs5M_riaIxusYQe4F=Ddxe2sX~i4t;2i{vmQ z)bLH4vKgvtB$ggHvmi!8I*YUzAD_7e2y&?rsFjd;>bS%xeLtEkGn-7wy$L3=74pwM zAF{tQx9>OeCr;%~pDmn7BHbSvKqJk>)6Ps;+zv}JpA4-X%$vC9z`uo2Hu9Rra zg(FXYznB{6@0Y#_qd`g_7(yT;KwlwJ8yRyFs_zMiK!Oa?muw$6x7~IIU0ZK&YH+Q; z9jSM1om&}n?WU!$zySRD27`@kbNI%Yx$}+HM4iRXdti4TMZMb5u8tkVYZlD5+#;`g zxytC|iQb=@^Q#z#cR>ULb8@VVU*DgD)njoMs(F5W<^2C@4E}%hAgeE_{~xKZevo!( z5xgt@gy2m$&w8bM6QZUAIoy)XO4Gq)Y4BC_RH)r=S-WXJ8EO|Q)ZVBxRFvIo3AMG7 z@F`YMX{@5ICM95MlM9L7Y#5$9Sa?|5vtZ?suZTXe&R|b4vGWvZWGY%x6s{8ijf{?I zqUF&sK_gRVfy?zH7I^&5Eby9H4~X@eZiPmmT|!OV<0`if7sT920uqXe~8+PrgXK9{}wmsK&A(B_#i? zVN{t|vOyOZ0_|f^fik-G@$P?#;BD}+SKiE1VFBMl{*GCW2abtvqu8X$f30Fj3@fdg zHo48<>s(RHYzSnV=04T6@9@z>`=lngN7ebrfn)nLGweQQhTYe#Iv(kg zIsqZ$A;=|8i{R}@ZK{yaY^vq`F^ioR(c{pYRbA*1*){E?Hr2IoC22DP)Vk7>-5EEw zZSPTf*b@c!&(#ls!I!vYf>k!MntFG_m7@;5o{Z6@6Rt+;jnj8>K#YG@Ab(q{@_#;Ii^h!3JN}8XH(K znZA1d%K3M`F%vj2?*4!ue|;vfACAE}@ZjagUwwQc{pR`C&rb>0eA8ff-BB(d?6wV| zS590!@y7DWQ&$sL5{0(y?|9w~UH^9e=!x93|Eh2_Hh!W|e{$S$tG=l!yy|QGurYYq z^Qz~y+KG;QV|3hg%i{P$KlfMMJXxC)HiPTpWNpE}nIt#Q3i|J+uz7rxW)$?~jIf!Q zO%d;g0Ci9dNap-{S(*vBx9;BcqLV^Sh~oVT1(ZS|8J!jM-%TNk_lFq;Jvk#p$^BvW z4FT$)7?1?{m8LlaTG7BtH!a zz|bULmz2iWa1_Bg1m_X_K7ta7b`0|nq~I^(Nt9?0%sJa!E2#*JC(T9J4me{b!p@(O zCc0r>nlR=8k=%=~={_M*HhoHo`BfP~HB5jLlscAcm;i?c0nSF~7h%kU=xKvYm^QMgRw#T{ zu_$Dq9-JO$*5^blHE&-4ghUfTFB=3Qu}KLkO~i0wnb?X17ys!ia}tnTa^%bLTySLnECY{ zG=D;Ez-TvN8T|6`l1*7| z_I*fiWN55+SsggGSb8wZu=G89f3qt`q`!+0?pi><`Gxg1OAV^LhO zWM7LFtkp##m)QvCm>_bkFtpkXmkU(lN^O*{6jzB6Wu-RC!&Q%RuApM=WM2X7#lg*b z53>|}k!u$9MP6O>uNXvmv}A+l;at@7Sabh*7~ZnQP{UpfY|2*#ad2BCv7;p&BWBsC@$Aq3THlKs*|aUG*&JgKh02m` z*k_KLT_tV;nzc%4BYbEzwBJW)e-z6YX#eIVqy4KGO|_)`9202&W<#&vP1?Vwq~qZb zI4V=QE*pV&CBh}Ne>U8K8P7!}v@KZ1(C=Ruen8y1WFv%6*RvcU91}*!Rzv@qjgSZ% zcj7k9n7B;r5IdD+8h5z5YVL3~tBLIycSvN!2q^HkQ|Ba@D76g+NT%-5R4U0LEMx-v zQxO^BE2Pgr*sjO`oTAQ2C^r!e_-CF@sw#q+@+?3VL~W?XdkHV63bb@wG;^1hx&Q^j zG`JHo#Q923+cV89gaXZBF$W}6(m+HkCq~4IYG|m^H=sfA1J#K!#F%m)NllS*A0~8> zIapA^HoQ(569kDbp-rZ`4za%#ge0O)q{R1@Nl6XZV1uBTwss+NYphNzyVMWSGbh%`Ri>pG3|g6r32!) zWmU(JEd6%rQ`m#^<3`5zL|0G~3+mTY=U0y&=+mdXQMdVpc+9K**vLo+I32~4atGd% zbQ$Y)eQPu>jj8Ta5O4y*Hq;~_0)*>SLW0m0EpVQA)2X$j53mQ-I@%qyKOvk*Opc|~ z5_xl^?_+^K1Q153%wj0to43HRtYWL^1K#C-*E( zz*(c+XNU9y#Q$zdcCWk!a>`qR*coFG2>LSj%cG!CmgUS!UAs)}KgH+&2bY&*~DXSKJoe!6!$0Jp56Uqe)p3z z^@qnD+8hAx&_^OeM00zOC-rqSZ;P0lEL5~XLh^B7{fKQV~2q^y#rcOq0`2(+9xOid0 z`SJ_1{&g^waPrvIZ(RAtPin63e`jTW$DzXZ$MfqSFZiFB^&idqj~4t-!Q_i&FadGZ zczOb^50e4$cat8|0}k_;OOFbAaz@&q8G4LC!vIPJPf|k60)2x8MoAj6-aF~Z&xEvxB^yCcU z(%c(Z7f0s_Kqj{s$@?QYVcX>60CN7w$#q0dQTh*N-On>XHQ-uRyQv$OA?1;5{w8!<@A^Am}p zASrNc41@ydJYvy244k4RXM~OOFe$R(W}}mjTs?5*0GKcFh7WKXCRi^?1~*31L>?@e zaKmV+M5CsbBu33h2`W@M`3Og4NkE-EP=X1EkWI{n<_5YDo!xWaKnHXPH){jkJoA@} zoByoLbM7TF&v}S>PB0haDzvY>d9HSW$`vm$&w=P&WS*;AGLdR=<~gIkrodsUC=0M$065^sTngf|Ayu4~rn47aoMBx??h1*da zhJS0e4MgG=ZNAPW=x-tiWNKN+!DsZTJJPFo8X5kNF2O_si`l@aXja`cOd$!MH#}|6 z>_>45BLXIdi6{%Q5A}nCgysiVMt(}B&c`JhTm+on(4MCIyGd`t;b%qBLqHamPC{<9 z(I8a04?cC^!0`ijBv!ddL#9`hm3X9SY+L16B7cS`M-VJX%pnqvF1FBUDZA+$<+ri6 z(*R_gq|Ia%(>)bn)}=9iv?icHP;oMOsb80AF`&4d=`17Blq>Kz*n%0;IZbyc;vC8E zJ~C6^XD;Fd+a?~!1vVN)l7CqeP8fLQiO`#EueW`F+2k|%Hi%ERE7#UB+qNU$2BOI> zfQ2?>sK?LcU~ibcL^5>N`5a9my9~t%M@`Z@4Czwto1TKoE3j6-0ONBk-$}X^`CNVBm^xq{8cUf6)qc>7X^^hL4OeCwrnOLCr~;KH z5uiDZ1pQjL26VdRF3@NhwWy|4mrWdAW2C#WBrGi=Lf~8CkqV)9Db?LphU%K5--Jub zzx#R6+)8T2smHEAJ@xQ^c;G#`@aWSY*ASJWgQyhUCMrd66{kVNxMtE)saPBOzcy#B z_u~dzV{=ioTg#GnPaO%daVrqX-vfu*`$Y1~K_nq&Nkv%~UHwElC$$M@7AZ}{9g{}C zuZK69Y-THSh+Y!?-iIX&(yU&_L;{A$rSN^g%@Bpe43A5*Vx8uu*DTgUd@Fsm2=0_@ zsL8}Vx$GQIjAj{z`?U|^r?OC-$U@n2#N!INhCNJi3^6Fu+chQ+%_fPd$5XL z1P>v27y*gUDMG4JaLzlHs&yOTQM_nzU@uj`a9kJ}teV&#Yl}!Ytmf4Z=G3IzZ(K1T zLzZgVN9cNyEf81#GQppNM#+D9Zxiv-u=5Kf#Kw5=G~W7B+0}r%2(@3{`|92|l2f~{ z?Va9xJyTfu2!YhUJuHwzn!W+)ZVip*P&gwTtU&lxYw1{@CxkPAtmg^A+Ilcg$kexK z(es4L)n`5%$#lsrW*)HHpna}|w6E%UM;L6Pvh-=%D~tf*ydzEa<^j#$*3BBQpjJil zLCqS0#kB!sJ#BWC$RuF@AiVgcTZQ%k+h98Ds7b7)Oav!oRykH39zUHJ}QW<>o%=-@*`zw%&=r ztGdVvM5=(xwBQ0Q3s+Ff_#ny%zuH9VRYuY|oH8sZqlvzo%ZR=vxDC)Ra*~ovp?Eo! z#X~aN zrccZWy}GvzspD@}&GsJ2_fl0f(4CeQXmICJE%@7T!T3Vq(Pyd=jH?!Jg+eHLSJ!o6 z;%#5F)^v*RkKp-Be}&-RBlwR9{tE&xf(8UQV^aD92GE!!M*!fo->=+uu6J#|-Dhuc z?Y`Z;+_mxcgKe(I>~p)?TpMX&EH?muzQMyM+Z?)XSxp8Wb4A^B0`OC0mDEujt}p?Z zE(WWuoXXzUqE%Ip+@MZsZZnnO7qJ$lVT$joE5wpEi%#vKt$- z2LX}8_hJeI{FyDBvG};#e}*+4K~Sv|?=eI!7R##<#_4Kov>%Ecai1bzc`AGtpYKA> z;kPO0P_hu)Lafv#&bFNRSEUuNBv7|c9L@!z28;IH(`p-+bDMxA(bhru%_%R5!MSeA zTO5NEGI)T^94?cg#o%Pw#z>qqLU#qB`IkUX?rY1fG@|UgY!GYpU>dqu`!C<5Tub-| zujs2_STFArShql*z!q^&ct;nQ(30_%*5RT!3ud@TJtC~9q> zZ6_kCMliUa6E)1PN9ojrt`wskTpIGCOP*v-h}Argg^`}m%OKl6(U%KsxhKdLlUB_| zxg;eol|(sRCre{DnQA-@f*lPpyZ~*fVjZXpvM|I1MOeZT7Ly2RANp|Bix+b7i;Wg--faLi zrCD5Ji1`Vb2t)dpU9B08oss?%jF-w9PwBq`%5-yig=b|4-2aRh_*(=E5mDr3C}FPO zq&~G&yU9sM8SSPi{g{U~dS8lTUW5)X;C?DAE5CbRS*dk>2@P6gF#LrnD;K*Qwc#p6q#<@*AN#7l?PYY3 zESO|Q;~GWZyakgg(>RDc2sEy~WWgk3Nb|d)3^fLkHltmf9$L^iP77ffJ*EuBW%Tp~ zlYTwQ(4eLa1!`Eb#GuvFL1ic`IRH<|@fN((xLMpYr^Xg|Tp$#~n!8tv*epV%M2-I6 z`QpX8GPJe4GSoo-o#$hTG*OP?r;fT8RjCb?I-p}r!)hS2ApH!$LKKR4{ZfS@<}rn$ z@%(p?F8mn4oz3j8M2)LbC{~v0+9wX?0&5MjSrsm}9NT3>1!PU5cij20U~A;@nKw-K zCIo^>;2#0 z`O+`-HB1q2&{ddsmw-2ww0Q;k9^HhjmMa4%^xL7 zHGcCx{PR(Nh%{jBJwXG?dX(G~U-Qa(xxcgi>fYDO!I;*g3yb%rUQU-;xQ?(2naETF zY0&=ZGLf$L(`BOadOzO+^?r2_>;Yw>V!hwe$wd4czaYJzl}yBC9SaJh^ZiUF3X!G7 zKM%bhn`%%|?vl0ZDV$M|vZ2*_TDg^03#iJQFArkFK`t74+Q^!phS-b_s8y_N5 z<88U^Y0>C9GS7ch3lF$lT$xOh}brD1iTL~3~S zQ78tAyq#pV5#vk|jM)h8w&&u~HaQ{2BdOu!*w)BEY#4orPl88r9Cl-ofn)+!(=4-= z=9;^s>hli5O_grqLlz=RQ8x7@-b5l30c1|Es?MU{GnWIC&_ zoTvhe%Ft~v$Wr>ADK?2mqF%q5x{Ui70>RNSaWI!`Ky2alzzqydJ_}q<#w_qSVS;&u zObJ&ks-Sj^Y=)ZJS)RUExMM$SrDkZV&Lfdd-w4BuOQM7uwUPBZlZIZF z?hC$VIS6r~3PK!C^HXbjdwZ*KKI{f$X$OK$2(}>Dir`xa&H|`L(?;jfX)9yX4_-ff z?TY8oJy`CGN-1^a(zE!Xe*wUJy_~=G<>$wpI5)s~%`@$p32rye4RBudT=7h`URyT3 zY)05wNiUZ!JbJ83FaP-~Z9_Os7E04u2(3rw{=2!-_J@2$?1%HX)>`+JNWAAGNJ&ZtWb;g~ugCcu>k0^cSGfreN|K&^3Q>qs2VVEAVs z%t(wQu_Ok45;15MvXBuP9k|RYU`Sf-yDff++!i@8&RGFqFX}CDxtwZ&r#mc` zTVo8Bl4LW~w36lNd#g3ZU@C)E5JPI`!ytwcyIoHpAmgl?9$2#IW*p^&o~}9>fssih2;kYM)2-K@6FF@e=ek4Q5_0q=D+u z+()bN93@7@>saTH5&UZeKSA(s5S#=M4Oj#{kPver(=MdHz!L8Os5Y7(U7!{TmmW(< z^A6Dz**nc>9_67b6#Es@UnjWU&1aw%GE?H7c4akT%i1P-bAk0-TYi_>vRnRpDgK|S zt#r7Ca6Og0Y46)g;eBS?LKF}gC?r;#7)kW%cc$iWXnaWdiI?VT4(~jREa!(z)f{vM zqq-7{tdS2ep?yVsh+hBFsX6Km3IG#62>3Nkys*IIybWLLw27>Bs#|22NX-Gle4v6b z|5F$;WTu4b7*0w5ghy4FX1|4a{~dsp(bPa~xVdy;)W*D67zmC0EOhmqQJYHQ`jWWO z?9T;O8;C=Z_Wa(aCb|OMZR!X6zi|lsGlCL{{C{B{f;r>}(EA--V0&*nSGac1J>1UJ z=7sy&N6RCV zR8==EqJ|9X+3+aE5|AC6bOixuz-`i>;#F!url&C@(Yb>xFHR#4pldGC{}a^$xto&P z{7iTdmdqJV#vhh`1<3f)%ijg?agE(>|HM&ecYfM#v)f;?{Z9Sg+qVC`ZNn!G4*TAT zr$4sAZffBA{^>!IeD~2g%=t8U$Zp^Li4EbW&pDRcSAJpx@aaMO*BthZpV$z7dcpaG k9S;zGdeYHH4iJ7i=xDX~d}0Ie>BA4&H{P}ZAli@r5A@Xzh5!Hn literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_roles.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_roles.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30b5d9cb008b59b70eed9386ca249f4b4cf82d72 GIT binary patch literal 18435 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 E0TGO(egFUf literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ecf674431f58f71ebf400ac2d6169a7baaba543 GIT binary patch literal 20036 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 `U5+#CC&NrvJ(qUILIp0f?gnk&t_WF2__dT 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 bkjM8$tKjim5q=u`BJcx`XU{zWB`e^6bC~U7 literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5e84575 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,304 @@ +"""Shared test fixtures — SQLite in-memory DB + FastAPI TestClient. + +This avoids needing MySQL for unit/integration tests. +All models are created fresh for every test function (function-scoped session). +""" +import sys, os + +# Ensure the backend app package is importable +# Backend is at ../../../HarborForge.Backend/ relative to this file +_backend_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "HarborForge.Backend")) +sys.path.insert(0, _backend_path) + +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +# --- Override engine BEFORE any app import touches the real DB --- +from app.core.config import Base + +# Force-import ALL model modules so Base.metadata knows every table +import app.models.models # noqa: F401 — User, Project, Comment, etc. +import app.models.milestone # noqa: F401 +import app.models.task # noqa: F401 +import app.models.role_permission # noqa: F401 +import app.models.activity # noqa: F401 +import app.models.propose # noqa: F401 +try: + import app.models.apikey # noqa: F401 +except ImportError: + pass +try: + import app.models.webhook # noqa: F401 +except ImportError: + pass + +TEST_DATABASE_URL = "sqlite://" # in-memory + +engine = create_engine( + TEST_DATABASE_URL, + connect_args={"check_same_thread": False}, + # Use StaticPool so all sessions share the same in-memory connection + poolclass=__import__("sqlalchemy.pool", fromlist=["StaticPool"]).StaticPool, +) + +# SQLite needs foreign keys enabled per-connection +@event.listens_for(engine, "connect") +def _set_sqlite_pragma(dbapi_conn, _): + cursor = dbapi_conn.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def setup_database(): + """Create all tables before each test, drop after.""" + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def db(): + """Yield a DB session for direct model manipulation.""" + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + + +@pytest.fixture() +def client(db): + """FastAPI TestClient wired to the test DB + a default authenticated user.""" + from fastapi.testclient import TestClient + from app.main import app + from app.core.config import get_db + + # Override DB dependency + def _override_get_db(): + try: + yield db + finally: + pass # caller's `db` fixture handles close + + app.dependency_overrides[get_db] = _override_get_db + yield TestClient(app) + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Helper factories +# --------------------------------------------------------------------------- + +@pytest.fixture() +def make_user(db): + """Factory to create a User row.""" + from app.models.models import User + + _counter = [0] + # Pre-compute a bcrypt hash for "password" to avoid passlib/bcrypt version issues + _pwd_hash = "$2b$12$LJ3m4ys/Xz.l1PaOHHKN/uE7dQFnSm1AUBfEkL0C2dN9.3Oau4XG" + + def _make(username=None, is_admin=False): + _counter[0] += 1 + n = _counter[0] + u = User( + username=username or f"testuser{n}", + email=f"test{n}@example.com", + hashed_password=_pwd_hash, + is_active=True, + is_admin=is_admin, + ) + db.add(u) + db.commit() + db.refresh(u) + return u + + return _make + + +@pytest.fixture() +def make_project(db): + """Factory to create a Project row.""" + from app.models.models import Project + + _counter = [0] + + def _make(owner_id, name=None, project_code=None): + _counter[0] += 1 + n = _counter[0] + p = Project( + name=name or f"TestProject{n}", + project_code=project_code or f"TP{n}", + owner_name="owner", + owner_id=owner_id, + ) + db.add(p) + db.commit() + db.refresh(p) + return p + + return _make + + +@pytest.fixture() +def make_milestone(db): + """Factory to create a Milestone row.""" + from app.models.milestone import Milestone, MilestoneStatus + + _counter = [0] + + def _make(project_id, created_by_id, status=MilestoneStatus.OPEN, **kw): + _counter[0] += 1 + n = _counter[0] + ms = Milestone( + title=kw.pop("title", f"Milestone {n}"), + project_id=project_id, + created_by_id=created_by_id, + status=status, + milestone_code=kw.pop("milestone_code", f"M{n:04d}"), + **kw, + ) + db.add(ms) + db.commit() + db.refresh(ms) + return ms + + return _make + + +@pytest.fixture() +def make_task(db): + """Factory to create a Task row.""" + from app.models.task import Task, TaskStatus + + _counter = [0] + + def _make(project_id, milestone_id, reporter_id, status=TaskStatus.PENDING, **kw): + _counter[0] += 1 + n = _counter[0] + t = Task( + title=kw.pop("title", f"Task {n}"), + project_id=project_id, + milestone_id=milestone_id, + reporter_id=reporter_id, + created_by_id=kw.pop("created_by_id", reporter_id), + status=status, + task_code=kw.pop("task_code", f"T{n:04d}"), + task_type=kw.pop("task_type", "issue"), + task_subtype=kw.pop("task_subtype", None), + **kw, + ) + db.add(t) + db.commit() + db.refresh(t) + return t + + return _make + + +@pytest.fixture() +def seed_roles_and_permissions(db): + """Create the minimal role + permission setup needed by action endpoints. + + Returns (admin_role, mgr_role, dev_role). + """ + from app.models.role_permission import Role, Permission, RolePermission + + # --- roles --- + admin_role = Role(name="admin", is_global=True) + mgr_role = Role(name="mgr", is_global=False) + dev_role = Role(name="dev", is_global=False) + db.add_all([admin_role, mgr_role, dev_role]) + db.commit() + + # --- permissions --- + perm_names = [ + ("milestone.freeze", "milestone"), + ("milestone.start", "milestone"), + ("milestone.close", "milestone"), + ("task.close", "task"), + ("task.reopen_closed", "task"), + ("task.reopen_completed", "task"), + ("propose.accept", "propose"), + ("propose.reject", "propose"), + ("propose.reopen", "propose"), + # add broad perms for role checks + ("project.read", "project"), + ("project.write", "project"), + ("milestone.read", "milestone"), + ("milestone.write", "milestone"), + ("milestone.create", "milestone"), + ("task.read", "task"), + ("task.write", "task"), + ("task.create", "task"), + ] + perm_objs = {} + for name, cat in perm_names: + p = Permission(name=name, category=cat, description=name) + db.add(p) + db.flush() + perm_objs[name] = p + + # admin gets all + for p in perm_objs.values(): + db.add(RolePermission(role_id=admin_role.id, permission_id=p.id)) + + # mgr gets milestone + propose + task management perms + mgr_perms = [ + "milestone.freeze", "milestone.start", "milestone.close", + "task.close", "task.reopen_closed", "task.reopen_completed", + "propose.accept", "propose.reject", "propose.reopen", + "project.read", "project.write", + "milestone.read", "milestone.write", "milestone.create", + "task.read", "task.write", "task.create", + ] + for name in mgr_perms: + db.add(RolePermission(role_id=mgr_role.id, permission_id=perm_objs[name].id)) + + # dev gets basic perms + dev_perms = [ + "project.read", "task.read", "task.write", "task.create", + "milestone.read", "task.close", "task.reopen_closed", "task.reopen_completed", + ] + for name in dev_perms: + db.add(RolePermission(role_id=dev_role.id, permission_id=perm_objs[name].id)) + + db.commit() + db.refresh(admin_role) + db.refresh(mgr_role) + db.refresh(dev_role) + return admin_role, mgr_role, dev_role + + +@pytest.fixture() +def make_member(db): + """Factory to add a user as project member with a given role.""" + from app.models.models import ProjectMember + + def _make(project_id, user_id, role_id): + pm = ProjectMember(project_id=project_id, user_id=user_id, role_id=role_id) + db.add(pm) + db.commit() + return pm + + return _make + + +@pytest.fixture() +def auth_header(): + """Generate a JWT auth header for a given user.""" + from app.api.deps import create_access_token + + def _make(user): + token = create_access_token({"sub": str(user.id)}) + return {"Authorization": f"Bearer {token}"} + + return _make diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..37777cc --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,59 @@ +"""P14.1 — Auth API tests. + +Covers: +- Login with valid credentials +- Login with invalid credentials +- Token refresh +- Protected endpoint access with/without token +""" +import pytest + + +class TestAuth: + """Authentication endpoints.""" + + def test_login_success(self, client, db, make_user): + """Valid login returns JWT token.""" + user = make_user(username="testuser", password="testpass123") + + resp = client.post( + "/auth/token", + data={"username": "testuser", "password": "testpass123"} + ) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + def test_login_invalid_password(self, client, db, make_user): + """Invalid password returns 401.""" + make_user(username="testuser", password="testpass123") + + resp = client.post( + "/auth/token", + data={"username": "testuser", "password": "wrongpass"} + ) + assert resp.status_code == 401 + + def test_login_nonexistent_user(self, client, db): + """Non-existent user returns 401.""" + resp = client.post( + "/auth/token", + data={"username": "nosuchuser", "password": "anypass"} + ) + assert resp.status_code == 401 + + def test_protected_endpoint_without_token(self, client): + """Accessing protected endpoint without token returns 401.""" + resp = client.get("/users/me") + assert resp.status_code == 401 + + def test_protected_endpoint_with_token(self, client, db, make_user, auth_header): + """Accessing protected endpoint with valid token succeeds.""" + user = make_user() + + resp = client.get("/users/me", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == user.id + assert data["username"] == user.username diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 0000000..e67da4d --- /dev/null +++ b/tests/test_comments.py @@ -0,0 +1,180 @@ +"""P14.1 — Comments API tests. + +Covers: +- List comments for task +- Create comment +- Update comment +- Delete comment +- Comment permissions +""" +import pytest + + +class TestComments: + """Comment management endpoints.""" + + def test_list_comments(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """List comments for a task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + from app.models.models import Comment + + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + # Add comments + comment1 = Comment(content="Comment 1", task_id=task.id, author_id=user.id) + comment2 = Comment(content="Comment 2", task_id=task.id, author_id=user.id) + db.add_all([comment1, comment2]) + db.commit() + + resp = client.get(f"/projects/{project.id}/tasks/{task.id}/comments", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + + def test_create_comment(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Create comment on task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + resp = client.post( + f"/projects/{project.id}/tasks/{task.id}/comments", + json={"content": "This is a test comment"}, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["content"] == "This is a test comment" + assert data["author_id"] == user.id + + def test_update_comment(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Update own comment.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + from app.models.models import Comment + + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + comment = Comment(content="Original", task_id=task.id, author_id=user.id) + db.add(comment) + db.commit() + + resp = client.patch( + f"/projects/{project.id}/tasks/{task.id}/comments/{comment.id}", + json={"content": "Updated content"}, + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["content"] == "Updated content" + + def test_delete_comment(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Delete comment.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + from app.models.models import Comment + + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + comment = Comment(content="To delete", task_id=task.id, author_id=user.id) + db.add(comment) + db.commit() + + resp = client.delete( + f"/projects/{project.id}/tasks/{task.id}/comments/{comment.id}", + headers=auth_header(user) + ) + assert resp.status_code == 204 + + def test_cannot_edit_others_comment(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Cannot edit another user's comment.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user1 = make_user(username="user1") + user2 = make_user(username="user2") + project = make_project() + make_member(project.id, user1.id, dev_role.id) + make_member(project.id, user2.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + from app.models.models import Comment + + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user1.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + comment = Comment(content="User1's comment", task_id=task.id, author_id=user1.id) + db.add(comment) + db.commit() + + resp = client.patch( + f"/projects/{project.id}/tasks/{task.id}/comments/{comment.id}", + json={"content": "Hacked!"}, + headers=auth_header(user2) + ) + assert resp.status_code == 403 diff --git a/tests/test_milestone_actions.py b/tests/test_milestone_actions.py new file mode 100644 index 0000000..7a3d61f --- /dev/null +++ b/tests/test_milestone_actions.py @@ -0,0 +1,358 @@ +"""P13.1 — Milestone state-machine action tests. + +Covers: +- freeze: success, missing release task, multiple release tasks, wrong status +- start: success + started_at, deps not met, wrong status +- close: from open/freeze/undergoing, wrong status (completed/closed) +- auto-complete: release task completion triggers milestone completed +""" +import json + +import pytest +from app.models.milestone import MilestoneStatus +from app.models.task import TaskStatus + + +# ----------------------------------------------------------------------- +# Freeze +# ----------------------------------------------------------------------- + +class TestFreeze: + """POST /projects/{pid}/milestones/{mid}/actions/freeze""" + + def test_freeze_success( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, _ = seed_roles_and_permissions + user = make_user(is_admin=False) + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id) + + # Create exactly 1 maintenance/release task + make_task( + project.id, ms.id, user.id, + task_type="maintenance", task_subtype="release", + ) + + resp = client.post( + f"/projects/{project.id}/milestones/{ms.id}/actions/freeze", + headers=auth_header(user), + ) + assert resp.status_code == 200, resp.text + assert resp.json()["status"] == "freeze" + + db.refresh(ms) + assert ms.status == MilestoneStatus.FREEZE + + def test_freeze_no_release_task( + self, client, db, make_user, make_project, make_milestone, + seed_roles_and_permissions, make_member, auth_header, + ): + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id) + + resp = client.post( + f"/projects/{project.id}/milestones/{ms.id}/actions/freeze", + headers=auth_header(user), + ) + assert resp.status_code == 400 + assert "no maintenance/release task" in resp.json()["detail"].lower() + + def test_freeze_multiple_release_tasks( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id) + + make_task(project.id, ms.id, user.id, task_type="maintenance", task_subtype="release") + make_task(project.id, ms.id, user.id, task_type="maintenance", task_subtype="release") + + resp = client.post( + f"/projects/{project.id}/milestones/{ms.id}/actions/freeze", + headers=auth_header(user), + ) + assert resp.status_code == 400 + assert "expected exactly 1" in resp.json()["detail"].lower() + + def test_freeze_wrong_status( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.CLOSED) + + make_task(project.id, ms.id, user.id, task_type="maintenance", task_subtype="release") + + resp = client.post( + f"/projects/{project.id}/milestones/{ms.id}/actions/freeze", + headers=auth_header(user), + ) + assert resp.status_code == 400 + assert "expected 'open'" in resp.json()["detail"].lower() + + +# ----------------------------------------------------------------------- +# Start +# ----------------------------------------------------------------------- + +class TestStart: + """POST /projects/{pid}/milestones/{mid}/actions/start""" + + def _freeze_milestone(self, db, ms): + ms.status = MilestoneStatus.FREEZE + db.commit() + db.refresh(ms) + + def test_start_success( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id) + self._freeze_milestone(db, ms) + + resp = client.post( + f"/projects/{project.id}/milestones/{ms.id}/actions/start", + headers=auth_header(user), + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["status"] == "undergoing" + assert "started_at" in data + + db.refresh(ms) + assert ms.status == MilestoneStatus.UNDERGOING + assert ms.started_at is not None + + def test_start_deps_not_met( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + + # Create a dependency milestone that is NOT completed + dep_ms = make_milestone(project.id, user.id, status=MilestoneStatus.OPEN) + + ms = make_milestone( + project.id, user.id, + depend_on_milestones=json.dumps([dep_ms.id]), + ) + self._freeze_milestone(db, ms) + + resp = client.post( + f"/projects/{project.id}/milestones/{ms.id}/actions/start", + headers=auth_header(user), + ) + assert resp.status_code == 400 + assert "cannot start" in resp.json()["detail"].lower() + + def test_start_wrong_status( + self, client, db, make_user, make_project, make_milestone, + seed_roles_and_permissions, make_member, auth_header, + ): + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.OPEN) + + resp = client.post( + f"/projects/{project.id}/milestones/{ms.id}/actions/start", + headers=auth_header(user), + ) + assert resp.status_code == 400 + assert "expected 'freeze'" in resp.json()["detail"].lower() + + +# ----------------------------------------------------------------------- +# Close +# ----------------------------------------------------------------------- + +class TestClose: + """POST /projects/{pid}/milestones/{mid}/actions/close""" + + @pytest.mark.parametrize("initial_status", [ + MilestoneStatus.OPEN, + MilestoneStatus.FREEZE, + MilestoneStatus.UNDERGOING, + ]) + def test_close_from_allowed_statuses( + self, client, db, make_user, make_project, make_milestone, + seed_roles_and_permissions, make_member, auth_header, initial_status, + ): + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=initial_status) + + resp = client.post( + f"/projects/{project.id}/milestones/{ms.id}/actions/close", + headers=auth_header(user), + json={"reason": "no longer needed"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["status"] == "closed" + + db.refresh(ms) + assert ms.status == MilestoneStatus.CLOSED + + @pytest.mark.parametrize("terminal_status", [ + MilestoneStatus.COMPLETED, + MilestoneStatus.CLOSED, + ]) + def test_close_from_terminal_rejected( + self, client, db, make_user, make_project, make_milestone, + seed_roles_and_permissions, make_member, auth_header, terminal_status, + ): + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=terminal_status) + + resp = client.post( + f"/projects/{project.id}/milestones/{ms.id}/actions/close", + headers=auth_header(user), + ) + assert resp.status_code == 400 + + +# ----------------------------------------------------------------------- +# Auto-complete +# ----------------------------------------------------------------------- + +class TestAutoComplete: + """When the sole release task is completed, milestone auto-completes.""" + + def test_auto_complete_on_release_task_finish( + self, db, make_user, make_project, make_milestone, make_task, + ): + """Direct unit test of try_auto_complete_milestone.""" + from app.api.routers.milestone_actions import try_auto_complete_milestone + + user = make_user() + project = make_project(owner_id=user.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + + release_task = make_task( + project.id, ms.id, user.id, + task_type="maintenance", task_subtype="release", + status=TaskStatus.COMPLETED, + ) + + try_auto_complete_milestone(db, release_task, user_id=user.id) + + db.refresh(ms) + assert ms.status == MilestoneStatus.COMPLETED + + def test_no_auto_complete_for_non_release_task( + self, db, make_user, make_project, make_milestone, make_task, + ): + from app.api.routers.milestone_actions import try_auto_complete_milestone + + user = make_user() + project = make_project(owner_id=user.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + + # Also add the required release task (still pending) + make_task( + project.id, ms.id, user.id, + task_type="maintenance", task_subtype="release", + status=TaskStatus.PENDING, + ) + + normal_task = make_task( + project.id, ms.id, user.id, + task_type="issue", task_subtype="defect", + status=TaskStatus.COMPLETED, + ) + + try_auto_complete_milestone(db, normal_task, user_id=user.id) + + db.refresh(ms) + assert ms.status == MilestoneStatus.UNDERGOING # unchanged + + def test_no_auto_complete_when_not_undergoing( + self, db, make_user, make_project, make_milestone, make_task, + ): + from app.api.routers.milestone_actions import try_auto_complete_milestone + + user = make_user() + project = make_project(owner_id=user.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.FREEZE) + + release_task = make_task( + project.id, ms.id, user.id, + task_type="maintenance", task_subtype="release", + status=TaskStatus.COMPLETED, + ) + + try_auto_complete_milestone(db, release_task, user_id=user.id) + + db.refresh(ms) + assert ms.status == MilestoneStatus.FREEZE # unchanged + + +# ----------------------------------------------------------------------- +# Preflight +# ----------------------------------------------------------------------- + +class TestPreflight: + """GET /projects/{pid}/milestones/{mid}/actions/preflight""" + + def test_preflight_freeze_allowed( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id) + make_task(project.id, ms.id, user.id, task_type="maintenance", task_subtype="release") + + resp = client.get( + f"/projects/{project.id}/milestones/{ms.id}/actions/preflight", + headers=auth_header(user), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["freeze"]["allowed"] is True + + def test_preflight_freeze_not_allowed( + self, client, db, make_user, make_project, make_milestone, + seed_roles_and_permissions, make_member, auth_header, + ): + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id) + # No release task + + resp = client.get( + f"/projects/{project.id}/milestones/{ms.id}/actions/preflight", + headers=auth_header(user), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["freeze"]["allowed"] is False diff --git a/tests/test_milestones.py b/tests/test_milestones.py new file mode 100644 index 0000000..4c42f20 --- /dev/null +++ b/tests/test_milestones.py @@ -0,0 +1,148 @@ +"""P14.1 — Milestones CRUD API tests. + +Covers: +- List milestones (project-scoped) +- Get milestone by ID +- Create milestone +- Update milestone +- Delete milestone +- Milestone filtering and sorting +""" +import pytest +from datetime import datetime, timedelta + + +class TestMilestonesCRUD: + """Milestone CRUD endpoints.""" + + def test_list_milestones(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """List milestones for a project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + # Create milestones + from app.models.milestone import Milestone, MilestoneStatus + milestone1 = Milestone(title="Milestone 1", project_id=project.id, status=MilestoneStatus.OPEN) + milestone2 = Milestone(title="Milestone 2", project_id=project.id, status=MilestoneStatus.OPEN) + db.add_all([milestone1, milestone2]) + db.commit() + + resp = client.get(f"/projects/{project.id}/milestones", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + + def test_get_milestone_by_id(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get specific milestone.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone( + title="Test Milestone", + description="Test desc", + project_id=project.id, + status=MilestoneStatus.OPEN + ) + db.add(milestone) + db.commit() + + resp = client.get( + f"/projects/{project.id}/milestones/{milestone.id}", + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == milestone.id + assert data["title"] == "Test Milestone" + + def test_create_milestone(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Create new milestone.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(project_code="PROJ") + make_member(project.id, user.id, dev_role.id) + + due_date = (datetime.now() + timedelta(days=30)).isoformat() + resp = client.post( + f"/projects/{project.id}/milestones", + json={ + "title": "New Milestone", + "description": "Milestone description", + "due_date": due_date + }, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "New Milestone" + assert data["status"] == "open" + assert data["milestone_code"].startswith("PROJ:") + + def test_update_milestone(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Update milestone (allowed in open status).""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone( + title="Old Title", + project_id=project.id, + status=MilestoneStatus.OPEN + ) + db.add(milestone) + db.commit() + + resp = client.patch( + f"/projects/{project.id}/milestones/{milestone.id}", + json={"title": "Updated Title"}, + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["title"] == "Updated Title" + + def test_delete_milestone(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Delete milestone.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone(title="To Delete", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + resp = client.delete( + f"/projects/{project.id}/milestones/{milestone.id}", + headers=auth_header(user) + ) + assert resp.status_code == 204 + + def test_milestone_status_filter(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Filter milestones by status.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + open_ms = Milestone(title="Open", project_id=project.id, status=MilestoneStatus.OPEN) + closed_ms = Milestone(title="Closed", project_id=project.id, status=MilestoneStatus.CLOSED) + db.add_all([open_ms, closed_ms]) + db.commit() + + resp = client.get( + f"/projects/{project.id}/milestones?status=open", + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert all(m["status"] == "open" for m in data) diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 0000000..fc6b57d --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,264 @@ +"""P14.1 — Misc API tests. + +Covers: +- Milestones global list +- Notifications +- Activity log +- API Keys +- Webhooks +- Export +- Dashboard stats +- Health check +""" +import pytest + + +class TestMilestonesGlobal: + """Global milestones endpoints.""" + + def test_list_all_milestones(self, client, db, make_user, auth_header): + """List all milestones (global endpoint).""" + user = make_user() + + resp = client.get("/milestones", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_list_milestones_with_project_filter(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Filter milestones by project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone(title="Test", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + resp = client.get(f"/milestones?project_id={project.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert all(m["project_id"] == project.id for m in data) + + def test_get_milestone_detail(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get milestone by ID (global endpoint).""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone(title="Test", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + resp = client.get(f"/milestones/{milestone.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == milestone.id + + def test_milestone_progress(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get milestone progress.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + + milestone = Milestone(title="Test", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + # Add tasks + task1 = Task( + title="Done", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.CLOSED, priority=TaskPriority.MEDIUM + ) + task2 = Task( + title="Open", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add_all([task1, task2]) + db.commit() + + resp = client.get(f"/milestones/{milestone.id}/progress", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert "total_issues" in data + assert "completed" in data + assert "progress_pct" in data + + +class TestNotifications: + """Notifications endpoints.""" + + def test_list_notifications(self, client, db, make_user, auth_header): + """List user notifications.""" + user = make_user() + + resp = client.get(f"/notifications?user_id={user.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_notification_count(self, client, db, make_user, auth_header): + """Get unread notification count.""" + user = make_user() + + resp = client.get(f"/notifications/count?user_id={user.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert "unread" in data + assert data["user_id"] == user.id + + def test_mark_notification_read(self, client, db, make_user, auth_header): + """Mark notification as read.""" + user = make_user() + + from app.models.notification import Notification + notification = Notification( + user_id=user.id, + type="test", + title="Test", + message="Test message", + is_read=False + ) + db.add(notification) + db.commit() + + resp = client.post(f"/notifications/{notification.id}/read", headers=auth_header(user)) + assert resp.status_code == 200 + assert resp.json()["status"] == "read" + + +class TestActivityLog: + """Activity log endpoints.""" + + def test_list_activity(self, client, db, make_user, auth_header): + """List activity logs.""" + user = make_user() + + resp = client.get("/activity", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_list_activity_with_filters(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Filter activity by entity.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + + from app.models.activity import ActivityLog + activity = ActivityLog( + action="create", + entity_type="project", + entity_id=project.id, + user_id=user.id, + details="Created project" + ) + db.add(activity) + db.commit() + + resp = client.get( + f"/activity?entity_type=project&entity_id={project.id}", + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert all(a["entity_type"] == "project" for a in data) + + +class TestAPIKeys: + """API Key management.""" + + def test_create_api_key(self, client, db, make_user, auth_header): + """Create API key.""" + user = make_user() + + resp = client.post( + "/api-keys", + json={"name": "Test Key", "user_id": user.id}, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Test Key" + assert "key" in data + + def test_list_api_keys(self, client, db, make_user, auth_header): + """List API keys.""" + user = make_user() + + resp = client.get("/api-keys", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_revoke_api_key(self, client, db, make_user, auth_header): + """Revoke API key.""" + user = make_user() + + resp = client.post( + "/api-keys", + json={"name": "To Revoke", "user_id": user.id}, + headers=auth_header(user) + ) + key_id = resp.json()["id"] + + resp = client.delete(f"/api-keys/{key_id}", headers=auth_header(user)) + assert resp.status_code == 204 + + +class TestDashboard: + """Dashboard stats.""" + + def test_dashboard_stats(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get dashboard statistics.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + resp = client.get("/dashboard/stats", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert "total" in data + assert "by_status" in data + assert "by_type" in data + assert "by_priority" in data + + def test_dashboard_stats_with_project_filter(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get dashboard stats for specific project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + resp = client.get(f"/dashboard/stats?project_id={project.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert "total" in data + + +class TestHealth: + """Health check.""" + + def test_health_check(self, client): + """Health endpoint returns ok.""" + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "healthy" + + def test_version(self, client): + """Version endpoint.""" + resp = client.get("/version") + assert resp.status_code == 200 + data = resp.json() + assert "version" in data + assert "name" in data diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..e3be968 --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,108 @@ +"""P14.1 — Projects API tests. + +Covers: +- List projects +- Get project by ID +- Create project +- Update project +- Delete project +- Project ownership and permissions +""" +import pytest + + +class TestProjects: + """Project management endpoints.""" + + def test_list_projects(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions): + """User can list projects they have access to.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project1 = make_project(name="Project 1") + project2 = make_project(name="Project 2") + + resp = client.get("/projects", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_get_project_by_id(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions): + """Get specific project details.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(name="Test Project", owner_id=user.id) + + resp = client.get(f"/projects/{project.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == project.id + assert data["name"] == "Test Project" + + def test_create_project(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """User can create project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + + resp = client.post( + "/projects", + json={ + "name": "New Project", + "description": "Test description", + "project_code": "TEST" + }, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "New Project" + assert data["project_code"] == "TEST" + assert "id" in data + + def test_update_project(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions): + """Project owner can update project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(name="Old Name", owner_id=user.id) + + resp = client.patch( + f"/projects/{project.id}", + json={"name": "Updated Name"}, + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "Updated Name" + + def test_delete_project(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions): + """Project owner can delete project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + + resp = client.delete(f"/projects/{project.id}", headers=auth_header(user)) + assert resp.status_code == 204 + + def test_non_owner_cannot_delete_project(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Non-owner cannot delete project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + owner = make_user(username="owner") + other = make_user(username="other") + project = make_project(owner_id=owner.id) + make_member(project.id, other.id, dev_role.id) + + resp = client.delete(f"/projects/{project.id}", headers=auth_header(other)) + assert resp.status_code == 403 + + def test_project_code_generation(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Project code is auto-generated if not provided.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + + resp = client.post( + "/projects", + json={"name": "Auto Code Project"}, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["project_code"].startswith("P") diff --git a/tests/test_propose.py b/tests/test_propose.py new file mode 100644 index 0000000..97469e2 --- /dev/null +++ b/tests/test_propose.py @@ -0,0 +1,559 @@ +"""P13.3 — Propose backend tests. + +Covers: +- CRUD: create, list, get, update +- propose_code per-project incrementing +- accept → auto-generate feature story task + feat_task_id +- accept with non-open milestone → fail +- reject → status change +- rejected → reopen back to open +- feat_task_id cannot be set manually +- edit restrictions (only open proposes editable) +- permission checks for accept/reject/reopen +""" +import pytest +from app.models.milestone import MilestoneStatus +from app.models.task import TaskStatus + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _propose_url(project_id: int, propose_id: int | None = None) -> str: + base = f"/projects/{project_id}/proposes" + return f"{base}/{propose_id}" if propose_id else base + + +# =========================================================================== +# CRUD +# =========================================================================== + +class TestProposeCRUD: + """Basic create / list / get / update.""" + + def test_create_propose( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id, project_code="PROJ") + make_member(project.id, user.id, dev_role.id) + + resp = client.post( + _propose_url(project.id), + json={"title": "New Feature Idea", "description": "Some details"}, + headers=auth_header(user), + ) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "New Feature Idea" + assert data["status"] == "open" + assert data["propose_code"].startswith("PROJ:P") + assert data["feat_task_id"] is None + + def test_list_proposes( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, dev_role.id) + + # Create two proposes + client.post(_propose_url(project.id), json={"title": "P1"}, headers=auth_header(user)) + client.post(_propose_url(project.id), json={"title": "P2"}, headers=auth_header(user)) + + resp = client.get(_propose_url(project.id), headers=auth_header(user)) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + def test_get_propose( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, dev_role.id) + + create_resp = client.post(_propose_url(project.id), json={"title": "P1"}, headers=auth_header(user)) + propose_id = create_resp.json()["id"] + + resp = client.get(_propose_url(project.id, propose_id), headers=auth_header(user)) + assert resp.status_code == 200 + assert resp.json()["title"] == "P1" + + def test_update_propose_open( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, dev_role.id) + + create_resp = client.post(_propose_url(project.id), json={"title": "Old"}, headers=auth_header(user)) + propose_id = create_resp.json()["id"] + + resp = client.patch( + _propose_url(project.id, propose_id), + json={"title": "New Title", "description": "Updated"}, + headers=auth_header(user), + ) + assert resp.status_code == 200 + assert resp.json()["title"] == "New Title" + assert resp.json()["description"] == "Updated" + + +# =========================================================================== +# Propose Code +# =========================================================================== + +class TestProposeCode: + """P1.4 — propose_code increments per project independently.""" + + def test_code_increments_per_project( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + proj_a = make_project(owner_id=user.id, project_code="ALPHA") + proj_b = make_project(owner_id=user.id, project_code="BETA") + make_member(proj_a.id, user.id, dev_role.id) + make_member(proj_b.id, user.id, dev_role.id) + + # Create 2 in ALPHA + r1 = client.post(_propose_url(proj_a.id), json={"title": "A1"}, headers=auth_header(user)) + r2 = client.post(_propose_url(proj_a.id), json={"title": "A2"}, headers=auth_header(user)) + + # Create 1 in BETA + r3 = client.post(_propose_url(proj_b.id), json={"title": "B1"}, headers=auth_header(user)) + + code1 = r1.json()["propose_code"] + code2 = r2.json()["propose_code"] + code3 = r3.json()["propose_code"] + + assert code1.startswith("ALPHA:P") + assert code2.startswith("ALPHA:P") + assert code3.startswith("BETA:P") + # They should be distinct + assert code1 != code2 + + +# =========================================================================== +# Accept +# =========================================================================== + +class TestAccept: + """P6.2 — accept propose → create feature story task.""" + + def test_accept_success( + self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN) + + create_resp = client.post( + _propose_url(project.id), + json={"title": "Cool Feature", "description": "Do something cool"}, + headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + resp = client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "accepted" + assert data["feat_task_id"] is not None + + # Verify the generated task exists + from app.models.task import Task + task = db.query(Task).filter(Task.id == int(data["feat_task_id"])).first() + assert task is not None + assert task.title == "Cool Feature" + assert task.description == "Do something cool" + assert task.task_type == "story" + assert task.task_subtype == "feature" + task_status = task.status.value if hasattr(task.status, "value") else task.status + assert task_status == "pending" + assert task.milestone_id == ms.id + + def test_accept_non_open_milestone_fails( + self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.FREEZE) + + create_resp = client.post( + _propose_url(project.id), + json={"title": "Feature X"}, + headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + resp = client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + assert resp.status_code == 400 + assert "open" in resp.json()["detail"].lower() + + def test_accept_already_accepted_fails( + self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + # First accept + client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + + # Second accept should fail + resp = client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + assert resp.status_code == 400 + + def test_accept_auto_fills_feat_task_id( + self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + resp = client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + data = resp.json() + assert data["feat_task_id"] is not None + + # Re-fetch to confirm persistence + get_resp = client.get(_propose_url(project.id, propose_id), headers=auth_header(mgr)) + assert get_resp.json()["feat_task_id"] == data["feat_task_id"] + + def test_accept_no_permission_fails( + self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, + ): + """dev role should not have propose.accept permission.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + owner = make_user() + dev_user = make_user() + project = make_project(owner_id=owner.id) + make_member(project.id, owner.id, mgr_role.id) + make_member(project.id, dev_user.id, dev_role.id) + + ms = make_milestone(project.id, owner.id, status=MilestoneStatus.OPEN) + + # Dev creates the propose + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(dev_user), + ) + propose_id = create_resp.json()["id"] + + # Dev tries to accept — should fail + resp = client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(dev_user), + ) + assert resp.status_code == 403 + + +# =========================================================================== +# Reject +# =========================================================================== + +class TestReject: + """P6.3 — reject propose.""" + + def test_reject_success( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + resp = client.post( + _propose_url(project.id, propose_id) + "/reject", + json={"reason": "Not needed"}, + headers=auth_header(mgr), + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "rejected" + + def test_reject_non_open_fails( + self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + # Accept first + client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + + # Now reject should fail + resp = client.post( + _propose_url(project.id, propose_id) + "/reject", + json={"reason": "Changed mind"}, + headers=auth_header(mgr), + ) + assert resp.status_code == 400 + + def test_reject_no_permission_fails( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + owner = make_user() + dev_user = make_user() + project = make_project(owner_id=owner.id) + make_member(project.id, owner.id, mgr_role.id) + make_member(project.id, dev_user.id, dev_role.id) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(dev_user), + ) + propose_id = create_resp.json()["id"] + + resp = client.post( + _propose_url(project.id, propose_id) + "/reject", + json={"reason": "nah"}, + headers=auth_header(dev_user), + ) + assert resp.status_code == 403 + + +# =========================================================================== +# Reopen +# =========================================================================== + +class TestReopen: + """P6.4 — reopen rejected propose.""" + + def test_reopen_success( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + # Reject first + client.post( + _propose_url(project.id, propose_id) + "/reject", + json={"reason": "wait"}, + headers=auth_header(mgr), + ) + + # Reopen + resp = client.post( + _propose_url(project.id, propose_id) + "/reopen", + headers=auth_header(mgr), + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "open" + + def test_reopen_non_rejected_fails( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + # Try reopen on open propose — should fail + resp = client.post( + _propose_url(project.id, propose_id) + "/reopen", + headers=auth_header(mgr), + ) + assert resp.status_code == 400 + + def test_reopen_no_permission_fails( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + owner = make_user() + dev_user = make_user() + project = make_project(owner_id=owner.id) + make_member(project.id, owner.id, mgr_role.id) + make_member(project.id, dev_user.id, dev_role.id) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(dev_user), + ) + propose_id = create_resp.json()["id"] + + # Owner rejects + client.post( + _propose_url(project.id, propose_id) + "/reject", + json={"reason": "nah"}, + headers=auth_header(owner), + ) + + # Dev tries to reopen — should fail + resp = client.post( + _propose_url(project.id, propose_id) + "/reopen", + headers=auth_header(dev_user), + ) + assert resp.status_code == 403 + + +# =========================================================================== +# feat_task_id protection +# =========================================================================== + +class TestFeatTaskIdProtection: + """P6.5 — feat_task_id is server-side only, cannot be set by client.""" + + def test_update_cannot_set_feat_task_id( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, dev_role.id) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(user), + ) + propose_id = create_resp.json()["id"] + + # Try to set feat_task_id via PATCH + resp = client.patch( + _propose_url(project.id, propose_id), + json={"feat_task_id": "999"}, + headers=auth_header(user), + ) + assert resp.status_code == 200 + # feat_task_id should still be None (server ignores it) + assert resp.json()["feat_task_id"] is None + + +# =========================================================================== +# Edit restrictions +# =========================================================================== + +class TestEditRestrictions: + """Propose editing is only allowed in open status.""" + + def test_edit_accepted_propose_fails( + self, client, db, make_user, make_project, make_milestone, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + ms = make_milestone(project.id, mgr.id, status=MilestoneStatus.OPEN) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + # Accept + client.post( + _propose_url(project.id, propose_id) + "/accept", + json={"milestone_id": ms.id}, + headers=auth_header(mgr), + ) + + # Try to edit + resp = client.patch( + _propose_url(project.id, propose_id), + json={"title": "Changed"}, + headers=auth_header(mgr), + ) + assert resp.status_code == 400 + assert "open" in resp.json()["detail"].lower() + + def test_edit_rejected_propose_fails( + self, client, db, make_user, make_project, seed_roles_and_permissions, make_member, auth_header, + ): + admin_role, mgr_role, dev_role = seed_roles_and_permissions + mgr = make_user() + project = make_project(owner_id=mgr.id) + make_member(project.id, mgr.id, mgr_role.id) + + create_resp = client.post( + _propose_url(project.id), json={"title": "F"}, headers=auth_header(mgr), + ) + propose_id = create_resp.json()["id"] + + # Reject + client.post( + _propose_url(project.id, propose_id) + "/reject", + json={"reason": "no"}, + headers=auth_header(mgr), + ) + + # Try to edit + resp = client.patch( + _propose_url(project.id, propose_id), + json={"title": "Changed"}, + headers=auth_header(mgr), + ) + assert resp.status_code == 400 diff --git a/tests/test_roles.py b/tests/test_roles.py new file mode 100644 index 0000000..45d171b --- /dev/null +++ b/tests/test_roles.py @@ -0,0 +1,182 @@ +"""P14.1 — Roles and Permissions API tests. + +Covers: +- List roles +- Get role by ID +- Create role +- Update role +- Delete role +- Assign role to user +- Check permissions +""" +import pytest + + +class TestRoles: + """Role management endpoints.""" + + def test_list_roles(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """List all roles.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + + resp = client.get("/roles", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 3 # admin, mgr, dev at minimum + + def test_get_role_by_id(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Get specific role.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + + resp = client.get(f"/roles/{admin_role.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == admin_role.id + assert "name" in data + + def test_create_role(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can create new role.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + + resp = client.post( + "/roles", + json={ + "name": "tester", + "description": "Test role", + "is_global": False + }, + headers=auth_header(admin) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "tester" + + def test_update_role(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can update role.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + + resp = client.patch( + f"/roles/{dev_role.id}", + json={"description": "Updated description"}, + headers=auth_header(admin) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["description"] == "Updated description" + + def test_delete_role(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can delete non-default role.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + + # Create a role to delete + resp = client.post( + "/roles", + json={"name": "temp-role", "description": "To delete"}, + headers=auth_header(admin) + ) + role_id = resp.json()["id"] + + resp = client.delete(f"/roles/{role_id}", headers=auth_header(admin)) + assert resp.status_code == 204 + + def test_cannot_delete_admin_role(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Cannot delete admin role.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + + resp = client.delete(f"/roles/{admin_role.id}", headers=auth_header(admin)) + assert resp.status_code == 400 + + +class TestPermissions: + """Permission checking endpoints.""" + + def test_check_permission_true(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Check permission returns true when granted.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + # Dev should have view permission + resp = client.get( + f"/projects/{project.id}/check-permission?permission=view", + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["has_permission"] is True + + def test_check_permission_false(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Check permission returns false when not granted.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + # Add as guest (viewer role) + from app.models.role_permission import Role + guest_role = db.query(Role).filter(Role.name == "guest").first() + if not guest_role: + guest_role = Role(name="guest", description="Guest", is_global=False) + db.add(guest_role) + db.commit() + make_member(project.id, user.id, guest_role.id) + + resp = client.get( + f"/projects/{project.id}/check-permission?permission=admin", + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["has_permission"] is False + + +class TestRoleAssignments: + """Role assignment endpoints.""" + + def test_assign_role_to_user(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Assign role to project member.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="member") + project = make_project() + + resp = client.post( + f"/projects/{project.id}/members", + json={"user_id": user.id, "role_id": dev_role.id}, + headers=auth_header(admin) + ) + assert resp.status_code == 201 + + def test_change_user_role(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Change user's role in project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="member") + project = make_project() + make_member(project.id, user.id, dev_role.id) + + resp = client.patch( + f"/projects/{project.id}/members/{user.id}", + json={"role_id": mgr_role.id}, + headers=auth_header(admin) + ) + assert resp.status_code == 200 + + def test_remove_user_from_project(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Remove user from project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="member") + project = make_project() + make_member(project.id, user.id, dev_role.id) + + resp = client.delete( + f"/projects/{project.id}/members/{user.id}", + headers=auth_header(admin) + ) + assert resp.status_code == 204 diff --git a/tests/test_task_transitions.py b/tests/test_task_transitions.py new file mode 100644 index 0000000..b4cdf6d --- /dev/null +++ b/tests/test_task_transitions.py @@ -0,0 +1,564 @@ +"""P13.2 — Task state-machine transition tests. + +Covers: +- pending → open: success, milestone not undergoing, deps not met +- open → undergoing: success, no assignee, non-assignee blocked +- undergoing → completed: success with comment, no comment fails, non-assignee blocked +- close from pending/open/undergoing: permission required +- reopen from completed/closed → open: distinct permissions +- invalid transitions: rejected by state machine +- edit restrictions: P5.7 body edit guards by status/assignee +""" +import json + +import pytest +from app.models.milestone import MilestoneStatus +from app.models.task import TaskStatus + + +# ----------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------- + +def _transition(client, task_id, new_status, headers, comment=None): + """POST /tasks/{id}/transition?new_status=...""" + body = {} + if comment is not None: + body["comment"] = comment + return client.post( + f"/tasks/{task_id}/transition?new_status={new_status}", + json=body, + headers=headers, + ) + + +# ----------------------------------------------------------------------- +# pending → open +# ----------------------------------------------------------------------- + +class TestPendingToOpen: + + def test_success( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """pending→open succeeds when milestone is undergoing and no deps.""" + admin_role, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task(project.id, ms.id, user.id, status=TaskStatus.PENDING) + + resp = _transition(client, task.id, "open", auth_header(user)) + assert resp.status_code == 200, resp.text + assert resp.json()["status"] == "open" + + def test_milestone_not_undergoing( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """pending→open rejected when milestone is still open.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.OPEN) + task = make_task(project.id, ms.id, user.id, status=TaskStatus.PENDING) + + resp = _transition(client, task.id, "open", auth_header(user)) + assert resp.status_code == 400 + assert "undergoing" in resp.json()["detail"].lower() + + def test_deps_not_satisfied( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """pending→open rejected when depend_on tasks are not completed.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + + dep_task = make_task(project.id, ms.id, user.id, status=TaskStatus.OPEN) + task = make_task( + project.id, ms.id, user.id, + status=TaskStatus.PENDING, + depend_on=json.dumps([dep_task.id]), + ) + + resp = _transition(client, task.id, "open", auth_header(user)) + assert resp.status_code == 400 + assert "depend" in resp.json()["detail"].lower() or "block" in resp.json()["detail"].lower() + + def test_deps_satisfied( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """pending→open succeeds when all depend_on tasks are completed.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + + dep_task = make_task(project.id, ms.id, user.id, status=TaskStatus.COMPLETED) + task = make_task( + project.id, ms.id, user.id, + status=TaskStatus.PENDING, + depend_on=json.dumps([dep_task.id]), + ) + + resp = _transition(client, task.id, "open", auth_header(user)) + assert resp.status_code == 200 + assert resp.json()["status"] == "open" + + +# ----------------------------------------------------------------------- +# open → undergoing +# ----------------------------------------------------------------------- + +class TestOpenToUndergoing: + + def test_success_assignee_starts( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Assignee can start their own task.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task( + project.id, ms.id, user.id, + status=TaskStatus.OPEN, + assignee_id=user.id, + ) + + resp = _transition(client, task.id, "undergoing", auth_header(user)) + assert resp.status_code == 200 + assert resp.json()["status"] == "undergoing" + db.refresh(task) + assert task.started_on is not None + + def test_no_assignee_fails( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Cannot start a task without an assignee.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task(project.id, ms.id, user.id, status=TaskStatus.OPEN) + + resp = _transition(client, task.id, "undergoing", auth_header(user)) + assert resp.status_code == 400 + assert "assignee" in resp.json()["detail"].lower() + + def test_non_assignee_blocked( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """A different user cannot start someone else's task.""" + _, mgr_role, _ = seed_roles_and_permissions + owner = make_user() + other = make_user() + project = make_project(owner_id=owner.id) + make_member(project.id, owner.id, mgr_role.id) + make_member(project.id, other.id, mgr_role.id) + ms = make_milestone(project.id, owner.id, status=MilestoneStatus.UNDERGOING) + task = make_task( + project.id, ms.id, owner.id, + status=TaskStatus.OPEN, + assignee_id=owner.id, + ) + + resp = _transition(client, task.id, "undergoing", auth_header(other)) + assert resp.status_code == 403 + assert "assigned" in resp.json()["detail"].lower() + + +# ----------------------------------------------------------------------- +# undergoing → completed +# ----------------------------------------------------------------------- + +class TestUndergoingToCompleted: + + def test_success_with_comment( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Assignee can complete a task with a completion comment.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task( + project.id, ms.id, user.id, + status=TaskStatus.UNDERGOING, + assignee_id=user.id, + ) + + resp = _transition(client, task.id, "completed", auth_header(user), comment="Done!") + assert resp.status_code == 200 + assert resp.json()["status"] == "completed" + db.refresh(task) + assert task.finished_on is not None + + def test_no_comment_fails( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Cannot complete without a comment.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task( + project.id, ms.id, user.id, + status=TaskStatus.UNDERGOING, + assignee_id=user.id, + ) + + resp = _transition(client, task.id, "completed", auth_header(user)) + assert resp.status_code == 400 + assert "comment" in resp.json()["detail"].lower() + + def test_empty_comment_fails( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Empty/whitespace comment is rejected.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task( + project.id, ms.id, user.id, + status=TaskStatus.UNDERGOING, + assignee_id=user.id, + ) + + resp = _transition(client, task.id, "completed", auth_header(user), comment=" ") + assert resp.status_code == 400 + assert "comment" in resp.json()["detail"].lower() + + def test_non_assignee_blocked( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Non-assignee cannot complete the task.""" + _, mgr_role, _ = seed_roles_and_permissions + owner = make_user() + other = make_user() + project = make_project(owner_id=owner.id) + make_member(project.id, owner.id, mgr_role.id) + make_member(project.id, other.id, mgr_role.id) + ms = make_milestone(project.id, owner.id, status=MilestoneStatus.UNDERGOING) + task = make_task( + project.id, ms.id, owner.id, + status=TaskStatus.UNDERGOING, + assignee_id=owner.id, + ) + + resp = _transition(client, task.id, "completed", auth_header(other), comment="I finished it") + assert resp.status_code == 403 + + +# ----------------------------------------------------------------------- +# Close task (from various states) +# ----------------------------------------------------------------------- + +class TestCloseTask: + + @pytest.mark.parametrize("initial_status", [ + TaskStatus.PENDING, + TaskStatus.OPEN, + TaskStatus.UNDERGOING, + ]) + def test_close_from_valid_states( + self, initial_status, + client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Close is allowed from pending/open/undergoing with permission.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task(project.id, ms.id, user.id, status=initial_status) + + resp = _transition(client, task.id, "closed", auth_header(user)) + assert resp.status_code == 200, resp.text + assert resp.json()["status"] == "closed" + + @pytest.mark.parametrize("initial_status", [ + TaskStatus.COMPLETED, + TaskStatus.CLOSED, + ]) + def test_close_from_terminal_states_fails( + self, initial_status, + client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Cannot close from completed or already closed.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task(project.id, ms.id, user.id, status=initial_status) + + resp = _transition(client, task.id, "closed", auth_header(user)) + assert resp.status_code == 400 + + def test_close_without_permission_fails( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """User without task.close permission cannot close.""" + from app.models.role_permission import Role + _, _, dev_role = seed_roles_and_permissions + + # Create a role with NO task.close permission + no_close_role = Role(name="viewer", is_global=False) + db.add(no_close_role) + db.commit() + + # Give viewer only basic perms (project.read, task.read) + from app.models.role_permission import Permission, RolePermission + for pname in ("project.read", "task.read"): + p = db.query(Permission).filter(Permission.name == pname).first() + if p: + db.add(RolePermission(role_id=no_close_role.id, permission_id=p.id)) + db.commit() + + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, no_close_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task(project.id, ms.id, user.id, status=TaskStatus.OPEN) + + resp = _transition(client, task.id, "closed", auth_header(user)) + assert resp.status_code == 403 + + +# ----------------------------------------------------------------------- +# Reopen (completed → open, closed → open) +# ----------------------------------------------------------------------- + +class TestReopen: + + def test_reopen_completed( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Reopen from completed → open with task.reopen_completed permission.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task(project.id, ms.id, user.id, status=TaskStatus.COMPLETED) + + resp = _transition(client, task.id, "open", auth_header(user)) + assert resp.status_code == 200 + assert resp.json()["status"] == "open" + # finished_on should be cleared + db.refresh(task) + assert task.finished_on is None + + def test_reopen_closed( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Reopen from closed → open with task.reopen_closed permission.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task(project.id, ms.id, user.id, status=TaskStatus.CLOSED) + + resp = _transition(client, task.id, "open", auth_header(user)) + assert resp.status_code == 200 + assert resp.json()["status"] == "open" + + def test_reopen_without_permission_fails( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """User without reopen permission cannot reopen.""" + from app.models.role_permission import Role, Permission, RolePermission + + # Create a role with task.close but NO reopen permissions + limited_role = Role(name="limited", is_global=False) + db.add(limited_role) + db.commit() + for pname in ("project.read", "task.read", "task.write", "task.close"): + p = db.query(Permission).filter(Permission.name == pname).first() + if p: + db.add(RolePermission(role_id=limited_role.id, permission_id=p.id)) + db.commit() + + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, limited_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task(project.id, ms.id, user.id, status=TaskStatus.COMPLETED) + + resp = _transition(client, task.id, "open", auth_header(user)) + assert resp.status_code == 403 + + +# ----------------------------------------------------------------------- +# Invalid transitions +# ----------------------------------------------------------------------- + +class TestInvalidTransitions: + + @pytest.mark.parametrize("from_status,to_status", [ + (TaskStatus.PENDING, "undergoing"), + (TaskStatus.PENDING, "completed"), + (TaskStatus.OPEN, "completed"), + (TaskStatus.OPEN, "pending"), + (TaskStatus.UNDERGOING, "open"), + (TaskStatus.UNDERGOING, "pending"), + (TaskStatus.COMPLETED, "undergoing"), + (TaskStatus.COMPLETED, "closed"), + (TaskStatus.CLOSED, "undergoing"), + (TaskStatus.CLOSED, "completed"), + ]) + def test_disallowed_transition( + self, from_status, to_status, + client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """State machine rejects transitions not in VALID_TRANSITIONS.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task( + project.id, ms.id, user.id, + status=from_status, + assignee_id=user.id, + ) + + resp = _transition(client, task.id, to_status, auth_header(user)) + assert resp.status_code == 400 + assert "cannot transition" in resp.json()["detail"].lower() + + +# ----------------------------------------------------------------------- +# Edit restrictions (PATCH) +# ----------------------------------------------------------------------- + +class TestEditRestrictions: + + def test_undergoing_body_edit_blocked( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Cannot PATCH body fields on an undergoing task.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task(project.id, ms.id, user.id, status=TaskStatus.UNDERGOING, assignee_id=user.id) + + resp = client.patch( + f"/tasks/{task.id}", + json={"title": "New Title"}, + headers=auth_header(user), + ) + assert resp.status_code == 400 + assert "undergoing" in resp.json()["detail"].lower() + + def test_completed_body_edit_blocked( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Cannot PATCH body fields on a completed task.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task(project.id, ms.id, user.id, status=TaskStatus.COMPLETED) + + resp = client.patch( + f"/tasks/{task.id}", + json={"title": "Changed"}, + headers=auth_header(user), + ) + assert resp.status_code == 400 + + def test_open_assignee_only_edit( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Open task with assignee: only assignee can edit body.""" + _, mgr_role, _ = seed_roles_and_permissions + owner = make_user() + other = make_user() + project = make_project(owner_id=owner.id) + make_member(project.id, owner.id, mgr_role.id) + make_member(project.id, other.id, mgr_role.id) + ms = make_milestone(project.id, owner.id, status=MilestoneStatus.UNDERGOING) + task = make_task( + project.id, ms.id, owner.id, + status=TaskStatus.OPEN, + assignee_id=owner.id, + ) + + # Other user cannot edit + resp = client.patch( + f"/tasks/{task.id}", + json={"title": "Hijack"}, + headers=auth_header(other), + ) + assert resp.status_code == 403 + + # Assignee can edit + resp = client.patch( + f"/tasks/{task.id}", + json={"title": "My Change"}, + headers=auth_header(owner), + ) + assert resp.status_code == 200 + assert resp.json()["title"] == "My Change" + + def test_open_no_assignee_anyone_edits( + self, client, db, make_user, make_project, make_milestone, + make_task, seed_roles_and_permissions, make_member, auth_header, + ): + """Open task without assignee: any project member can edit.""" + _, mgr_role, _ = seed_roles_and_permissions + user = make_user() + project = make_project(owner_id=user.id) + make_member(project.id, user.id, mgr_role.id) + ms = make_milestone(project.id, user.id, status=MilestoneStatus.UNDERGOING) + task = make_task( + project.id, ms.id, user.id, + status=TaskStatus.OPEN, + assignee_id=None, + ) + + resp = client.patch( + f"/tasks/{task.id}", + json={"title": "Anyone's Change"}, + headers=auth_header(user), + ) + assert resp.status_code == 200 + assert resp.json()["title"] == "Anyone's Change" diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..8263e26 --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,211 @@ +"""P14.1 — Tasks CRUD API tests. + +Covers: +- List tasks (project-scoped, milestone-scoped) +- Get task by ID +- Create task +- Update task +- Delete task +- Task filtering by status, assignee, etc. +""" +import pytest +from datetime import datetime, timedelta + + +class TestTasksCRUD: + """Task CRUD endpoints.""" + + def test_list_tasks(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """List tasks for a project.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + # Create milestone and tasks + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task1 = Task( + title="Task 1", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + task2 = Task( + title="Task 2", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add_all([task1, task2]) + db.commit() + + resp = client.get(f"/projects/{project.id}/tasks", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 2 + + def test_get_task_by_id(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Get specific task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Test Task", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.HIGH, + task_type="issue" + ) + db.add(task) + db.commit() + + resp = client.get(f"/projects/{project.id}/tasks/{task.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == task.id + assert data["title"] == "Test Task" + assert data["task_type"] == "issue" + + def test_create_task(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Create new task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project(project_code="PROJ") + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + resp = client.post( + f"/projects/{project.id}/tasks", + json={ + "title": "New Task", + "description": "Task description", + "milestone_id": milestone.id, + "task_type": "issue", + "priority": "high" + }, + headers=auth_header(user) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "New Task" + assert data["status"] == "open" + assert data["task_code"].startswith("PROJ:") + + def test_update_task(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Update task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="Old Title", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + resp = client.patch( + f"/projects/{project.id}/tasks/{task.id}", + json={"title": "Updated Title"}, + headers=auth_header(user) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["title"] == "Updated Title" + + def test_delete_task(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Delete task.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + task = Task( + title="To Delete", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(task) + db.commit() + + resp = client.delete(f"/projects/{project.id}/tasks/{task.id}", headers=auth_header(user)) + assert resp.status_code == 204 + + def test_task_filter_by_status(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Filter tasks by status.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + open_task = Task( + title="Open", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + closed_task = Task( + title="Closed", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, status=TaskStatus.CLOSED, priority=TaskPriority.MEDIUM + ) + db.add_all([open_task, closed_task]) + db.commit() + + resp = client.get(f"/projects/{project.id}/tasks?status=open", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert all(t["status"] == "open" for t in data) + + def test_task_filter_by_assignee(self, client, db, make_user, make_project, auth_header, seed_roles_and_permissions, make_member): + """Filter tasks by assignee.""" + admin_role, mgr_role, dev_role = seed_roles_and_permissions + user = make_user() + assignee = make_user(username="assignee") + project = make_project() + make_member(project.id, user.id, dev_role.id) + + from app.models.milestone import Milestone, MilestoneStatus + from app.models.task import Task, TaskStatus, TaskPriority + milestone = Milestone(title="M1", project_id=project.id, status=MilestoneStatus.OPEN) + db.add(milestone) + db.commit() + + assigned_task = Task( + title="Assigned", project_id=project.id, milestone_id=milestone.id, + reporter_id=user.id, assignee_id=assignee.id, + status=TaskStatus.OPEN, priority=TaskPriority.MEDIUM + ) + db.add(assigned_task) + db.commit() + + resp = client.get(f"/projects/{project.id}/tasks?assignee_id={assignee.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert all(t["assignee_id"] == assignee.id for t in data) diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..5f5fb91 --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,100 @@ +"""P14.1 — Users API tests. + +Covers: +- List users +- Get user by ID +- Create user +- Update user +- Delete user +- User self-service restrictions +""" +import pytest + + +class TestUsers: + """User management endpoints.""" + + def test_list_users(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can list all users.""" + seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + make_user(username="user1") + make_user(username="user2") + + resp = client.get("/users", headers=auth_header(admin)) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 2 + + def test_get_user_by_id(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Get specific user details.""" + seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="testuser") + + resp = client.get(f"/users/{user.id}", headers=auth_header(admin)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == user.id + assert data["username"] == "testuser" + + def test_create_user(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can create new user.""" + seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + + resp = client.post( + "/users", + json={ + "username": "newuser", + "password": "newpass123", + "is_admin": False + }, + headers=auth_header(admin) + ) + assert resp.status_code == 201 + data = resp.json() + assert data["username"] == "newuser" + assert "id" in data + + def test_update_user(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can update user.""" + seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="testuser") + + resp = client.patch( + f"/users/{user.id}", + json={"username": "updateduser"}, + headers=auth_header(admin) + ) + assert resp.status_code == 200 + data = resp.json() + assert data["username"] == "updateduser" + + def test_delete_user(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Admin can delete user.""" + seed_roles_and_permissions + admin = make_user(username="admin", is_admin=True) + user = make_user(username="testuser") + + resp = client.delete(f"/users/{user.id}", headers=auth_header(admin)) + assert resp.status_code == 204 + + def test_regular_user_cannot_list_all_users(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """Non-admin cannot list all users.""" + seed_roles_and_permissions + user = make_user(username="regular") + + resp = client.get("/users", headers=auth_header(user)) + assert resp.status_code == 403 + + def test_user_can_view_self(self, client, db, make_user, auth_header, seed_roles_and_permissions): + """User can view their own profile.""" + seed_roles_and_permissions + user = make_user(username="testuser") + + resp = client.get(f"/users/{user.id}", headers=auth_header(user)) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == user.id