From be00f021ba1484d8bde0395614b65dfbc5cbf992 Mon Sep 17 00:00:00 2001 From: c-d-p Date: Wed, 23 Apr 2025 00:51:14 +0200 Subject: [PATCH] Added full suite of tests & added testing to CI/CD --- .github/workflows/deploy.yml | 60 ++- .vscode/settings.json | 7 + .../__pycache__/celery_app.cpython-312.pyc | Bin 467 -> 436 bytes .../core/__pycache__/config.cpython-312.pyc | Bin 1889 -> 1353 bytes backend/core/config.py | 8 +- .../admin/__pycache__/api.cpython-312.pyc | Bin 1691 -> 1762 bytes .../calendar/__pycache__/api.cpython-312.pyc | Bin 2999 -> 3080 bytes .../__pycache__/models.cpython-312.pyc | Bin 1087 -> 1165 bytes .../__pycache__/schemas.cpython-312.pyc | Bin 2777 -> 2880 bytes backend/modules/calendar/api.py | 4 +- backend/modules/calendar/models.py | 3 +- backend/modules/calendar/schemas.py | 2 + backend/requirements-dev.txt | 4 + .../test_admin.cpython-312-pytest-8.3.5.pyc | Bin 5649 -> 11514 bytes .../test_auth.cpython-312-pytest-8.3.5.pyc | Bin 23885 -> 28710 bytes ...test_calendar.cpython-312-pytest-8.3.5.pyc | Bin 4905 -> 52694 bytes .../test_main.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 2160 bytes .../test_nlp.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 27018 bytes .../test_todo.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 26725 bytes .../__pycache__/generators.cpython-312.pyc | Bin 2270 -> 2352 bytes backend/tests/helpers/generators.py | 5 +- backend/tests/test_admin.py | 79 ++++ backend/tests/test_auth.py | 62 ++- backend/tests/test_calendar.py | 411 ++++++++++++++++-- backend/tests/test_main.py | 10 + backend/tests/test_nlp.py | 218 ++++++++++ backend/tests/test_todo.py | 210 +++++++++ 27 files changed, 1035 insertions(+), 48 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 backend/requirements-dev.txt create mode 100644 backend/tests/__pycache__/test_main.cpython-312-pytest-8.3.5.pyc create mode 100644 backend/tests/__pycache__/test_nlp.cpython-312-pytest-8.3.5.pyc create mode 100644 backend/tests/__pycache__/test_todo.cpython-312-pytest-8.3.5.pyc create mode 100644 backend/tests/test_admin.py create mode 100644 backend/tests/test_main.py create mode 100644 backend/tests/test_nlp.py create mode 100644 backend/tests/test_todo.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6381504..6777f4f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,8 +19,66 @@ on: - cron: '0 3 * * 0' jobs: - build-and-deploy: + # ======================================================================== + # Job to run unit tests. + # ======================================================================== + test: + name: Run Linters and Tests runs-on: ubuntu-latest + steps: + # Checks out the repo under $GITHUB_WORKSPACE + - name: Checkout code + uses: actions/checkout@v4 + + # Sets up Python 3.12 environment + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + # Cache pip dependencies for faster reruns + - name: Cache pip dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + working-directory: ./backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Lint with Ruff + working-directory: ./backend + run: | + ruff check . + + - name: Check formatting with Black + working-directory: ./backend + run: | + black --check . + + - name: Run Pytest + working-directory: ./backend + run: | + pytest + + # ======================================================================== + # Job to build and deploy the Docker image to mara. + # ======================================================================== + build-and-deploy: + name: Build and Deploy + runs-on: ubuntu-latest + needs: test # Ensure tests pass before deploying + + # Only run this job if triggered by a push to main or manual dispatch/schedule + # This prevents it running for PRs (eventually) + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + steps: # Checks out the repo under $GITHUB_WORKSPACE - name: Checkout code diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7581cbf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "backend" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/backend/core/__pycache__/celery_app.cpython-312.pyc b/backend/core/__pycache__/celery_app.cpython-312.pyc index 8919cbeb61af57c6425b61dea27c34e6371d3c48..ed5aaec278d08baf8d04574d19403d6de1c68ed8 100644 GIT binary patch delta 93 zcmcc2yoH(XG%qg~0}w<$X3u!TIFT=ukz-)M;dO=4TN19Ulz7VpH_u-A8FBCil1~3E*GzCo%7!Fv0rl^S=ix$)%hMPRrV0&-g zjGyI<{f{#FA4+*&=&c%XKhQ&3H~D;SII`alp+&dFbm)k&v_N9_ zbPrdt@0)hRqdd4^V#_G*gfbZ=gY7Ts{at*bRp8_JJ32iD7-)h4w7?q8UK26IK_hUR zdJIvMID?!8wGd|#XCY0-VXBll3u_V1B8+iW^c2OoBCbXGdV(>YU6N)ubeeH&dpJEp zHFC`2CM-D7&&)suXHVt4EjUsmMe&ms6`%R4Xm3MWlXG zE>u*sS}3m7)ap@)DN+@6p`uoo&Pwvx2UQ}K)pA*_kOT`{RdW@!hL+U_1dzmHskFGF zqHMXqR?f7d-+FL7PQ$-x1r{|(n?W0)Nwm%IBsnNdCg<4eW@9I~<*$Emi%4d}c3fOc zhlq@jtv4}3B!W=WF}5s*V+cLm(yhKHnlIJV;zy{Qtt}Cm-V3yDT9^zE5W;rdG3Xs4 zLHwof>OX;FdMrEEA&tkjK`Lj71-%@(rCXL|i}Y#VgNr7nJ~OlzA+y%V~lbYqHs z-Uv01z~Q+|yK?v9I|p*AJ2bu*bXm#WE7TOzarZ4o#+YG+aCxys&v!`@A>FneU-wPN z_7HMq=4Y?ajZs5H)Arp2qwF_4o;%494}X~T+pAAzI`B&Oz4QUR{KL5pOn2YCdH^S%QN`76EcwlHcXX@+$EbK>vR!;a zgIxRv!eHueKuzzz?3d{n8E$PGy6u~FmL{!WAmfn6eqMzsNKqrfY5|SP-8aW0Jghp3TzQ0*Rusvdzb7w zl|WTQrBsU43!I2g#wVxZ(qsOFO1-SKJ)o&7QMEmwURse;PMukd{l_8W7igToKshr!Je6%E{k%xTu?Uk6q)EOsyPIa zT9%cs^9`)kkut4z@u!WJB?E~;K(Z|Znca3Zd@bE}XUm{rA_mz`$ZFaohwPMHh`CSf zHV7=@nuHFEz_v-SBS(|qmODue_BIJl3}<&&bC&lR_bZ}QIg*r?^eZh@aoif}$H zW^qR|;A%RP7P5uE2LQnS1i#1&Yj8$LFYtnuW_Td@Y_x@S+H_1e}8WY9=Z0a4DHu&hp}U zi0l#%3j70tntwB7HohjFJE`B1;WHUtzyNpA3?iQp_$*xD*KpTdIz6|@!+0i1$Ek2H zc0s7*N-~7l4Pi+|kBijyK=|l!uGsL{R2@6X-BQq01+$xSS+nx6dRfCh2y=>}mUHET zs^}1E^j^{EO^MlpQr75|qIsxzsrab)sq|3kqtZ|02XH|YypF`S)RG*_qn%h@MRIH@ zo{Y!7$mPG16+~t<;!>3_3!BlMUClukQ=uvK_ZR@a1E+!BqsU?8D0&z@9xy&wF$1gn z51u7VZuP8x;As1B`$#!dj@N(u*zBL)e|W%}-1O-ij&Za9?xo}I+0gZ>RFSG1l?~&= zX(Rr*IkawYeFwV9t)C9RTMyQP^-wKj+@3R%d2<*U+~D&ulS5~N*Q&uvuo|j_Uie}p2cpmP@K9b&z9-M{>FrnFmRNPY>R uzfa^JmSLE`K;&1@@f!&J4mzKDUvYkB`q?+H0O|g{1w6gqPk!sNNdE!XkCPDq diff --git a/backend/core/config.py b/backend/core/config.py index 80c6211..05f3642 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -3,12 +3,14 @@ from pydantic_settings import BaseSettings from pydantic import Field # Import Field for potential default values if needed import os +DOTENV_PATH = os.path.join(os.path.dirname(__file__), "../.env") + class Settings(BaseSettings): # Database settings - reads DB_URL from environment or .env - DB_URL: str + DB_URL: str = "postgresql://maia:maia@localhost:5432/maia" # Redis settings - reads REDIS_URL from environment or .env, also used for Celery. - REDIS_URL: str + REDIS_URL: str ="redis://localhost:6379/0" # JWT settings - reads from environment or .env JWT_ALGORITHM: str = "HS256" @@ -22,7 +24,7 @@ class Settings(BaseSettings): class Config: # Tell pydantic-settings to load variables from a .env file - env_file = '.env' + env_file = DOTENV_PATH env_file_encoding = 'utf-8' extra = 'ignore' diff --git a/backend/modules/admin/__pycache__/api.cpython-312.pyc b/backend/modules/admin/__pycache__/api.cpython-312.pyc index e2753a4cfd558da600bb4db2ef4f6bdc140627b9..06c11ba87d20ed80a98f7601c0d931d902716b28 100644 GIT binary patch delta 201 zcmbQu`-qqKG%qg~0}wbqVb6%%$UA|V(QooRW_O+x#u}y+ra4T(44TZ7UovkttWtK) zNlh%u%u82DNi0cBN-R!Q$jnnH$w*a5N=(j9FUl{?OVMO1QUn@SBnBiV&t-AdPysR; z7(VbZ@Cf(YblNny++pEv_igl@pmK#p{D!#Afs~8f-WOQBC;w;p#W;8JFVsvFS(;Un z`6`R}WGB{Nj58!4*G{q>bqpZ%mN%GU~07;zOrC=n`EMT({f+EiB)I@Kebat&cnBf^@S z7A7@ChGrsNce5f#a}hS&ys)Sh;j~*2MOuuo>6S#9mLr^T7es|tM3v5}!lrg)%({zW zi7ts6t%*9Viw13oCT$W$RXAZemOmtC)+v;Wi?(>{_;!qwk`=hkQ^#9LTa3?&r>PhL zpUQKm1htuh^eVSvRZ#{iEAa{|^DGoVnd?2aG?K)V_s^T?;Ucld;lm=_wJ@Ay-%L&Ee&-1COx5kQkU51*-TldEc2kldS$M;`BqG2?ESi{pEJ z(1H&%XR!?2p*m!plxzZ&EfiORpS2IkYEaQPwGNT>;4S^*cb2gVRaP*djaZupqv16a z*Fbn0h&C*_0wByDa=*tv!metJoyb7h-|XI>%`Hmec3xBA|y;f zL5sFP@B7J7>qb1dzx|H~MCF=lgbMR`;~{wvc?EOxlWE z?_Q|ehtHCw{wq~co-0Hl$Wa)=h^bgb;HyrIX;=$!%+Z;S^$=^0!Axv23tOSDJ8@=X zJH&=F#u7LYV$(^o6i$WMa?&h=Ga-&U<1CA_EQfO}kNf#BXgd>Z5>K)MF0djlvJx(_ zDLjP~RiTKCk;E1{Nbbw4!op=*-nl%$QA<)Ze~>;tB{NlO@1wzq&`TV9EtO-n`T8`IHCd26btN9HwHo&^QWGoO*7ziUy7(Q`?|r-O^~W%1gq6oK{VgEUnXjCp>;?|Jp6?3nm$ z-dr83Z< z1-gdElaQGLla}F`K1`m6QwZl8whgKZ3_f^0n%nh!J_n(gY(HNNQM=>wB|y5{@SEFw z8L%;^lGmj3M&VFk_y~SkSL9RcIdqj@l`$kg@0+yWV@OBni&7C2_L}~<{0XgyrhN&m L9=^19P&n4#7FiMl diff --git a/backend/modules/calendar/__pycache__/models.cpython-312.pyc b/backend/modules/calendar/__pycache__/models.cpython-312.pyc index 31f3ae1d95e3f6ec046d47c62362bcd005b773e9..f86f1e9a9937c8609d2ecc42d83013904566f3d6 100644 GIT binary patch delta 451 zcmdnb(aXtqnwOW00SGu`IWnA>C-O-!hD}sgw$EjcVrOJXVMt-h;mGBT;smpqbGUN3 zqqrFvm>Ap{Qdn9TQdm>DS2KgOGcZK)RI+KZzXUNg*(bJ|a^7Nh%FoY9P0X9POONr{ z#Fxz(DJ*N)Rx^QA0G0DIF{HAj3an;?@&&w0Kz-Y^@$#{!BB{eOvG^b?pUq%&1@ySX|WxSj~+itNZ=H$euBvwwI z&ZNl5HF+nKCqFMxya+_F0daBrWI<+cZa$F-;R{l)@aZ(TPmX3bZK%>Bqk*mrxtO;v=%9YSYVS>fvjH~ zHo5sJr8%i~MVde^C@hMVfW!x8Mn=Zx48hkKf-f=z-(^t0%OLlKg`d%hae~WN1`rK2 H0~nG3v-e?i delta 370 zcmeC>+|R*xnwOW00SHQGGG%;Wn#d=?7&K8`*_x3dg&~D0hdq}giUZ7M&f(1EisE8q zU}A7*NMUJVNMTLoTFng7%D@oCUCE}&{u0F0WSiJ&I`Oz3*??q>@vUYw|-z+sT?tDvY9& z{g}!oUt&^Vz&F3d^H zNzO>kt<=je$}Qpqs<_3QoL`ixmy%eLn3PzYS_BFeuxUjKAQspVB_Qh;hfQvNN@-52 uU6C4)3-VyGJdpUn%*e?2oWbilgV$XK^}7smUs(7Uofs#$d}RR9U;_YrMowh_ diff --git a/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc b/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc index 829083ae22476d9f1ca5b8822070dcdfd6cd1b38..cbfbd31f72c4da78ac15d63c7464665526dad489 100644 GIT binary patch delta 928 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 literal 5649 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 PUNrxtVUd7q$eBL?Uc9b! diff --git a/backend/tests/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc index c04a8ee0a98c1cbd19f43ec9214f34f9f85a9818..c5e604174324fda528fd25c3dc08263944b1feda 100644 GIT binary patch literal 52694 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 ByQTmD literal 4905 zcmds5&2JmW72hS7UrX^zpSIM>l;x^oTk}JdEX9p%t4ZLtPJCzs6c7p!YtBj}z5M9x zDzW6&{J+Kl#@<P}d;C*s{_~j4@5Pr>1f*%O013kJB=pvhZO8gSL zy`ADs@m*Qf-cZVNO#>J%QcxwTAiwZjk^K zUickrKtJX*-ma^-tvX7bf7+@4In){+!|Spa?3PZc^SY;E2g*@%y7wve36CM@0^!di z=L62eIKR$SLah1}AzmU7AMro-K$wEM?|0nqJ!PJFbbp!uW9Vt8eY=-{(3_}>>YucTZBvQboKuppF0S!p0Nbp2Z2u&pC2X6@pv^hu(+>L{r;fP% z4?7k%rw8n4Qn%%(UGJ2RWsQgaol*C*4ccB`VHdLPNc6FH3$kQ{NsPqxaG7V)fQHFn zkIU1zt#e9!!1iR9zlLRSk2iww!7Gd=kwEA7SW=5#IMrE~Ri6_GusZq`pB}J|giDu;YV7;p+Z1E@GLg8e1E;~1W zIh(&cmwh+4a5bBScXi)m`n0m9$fl@j64eS?xhk8Hl?|;_r{yh4gT3M;@B>+*GEMG# zO`)RJYi6J%OGJi!BT%f@@0Mlt%fCRv_W2Xf^71~vmH*akV{DAbd=tUQ=c0#S6Ab=8!C$L?u;~sm6RPf+;8$X&ib?@mquF zIG0jwSMw<~4Ys7Vk6p;1>g zGwd3$Dc)#oYOtLaBx)uK5NxGDUSiizYjE^2A21B&j8t6dDky_v@6HUaB@IZf`V<A+#1Gh<8r4Bd6@y9`(#g@+z3?<{|KeRr`H zo^A^HJsn6hJiVv4!q5cY7afHq9Wcq_pP{|Qy6JNve+)6@PM!aF&a(4A4GZW0;r!=0 zKo7V!dLBRzFQW#ri$f4K4D>Vs5l}<;CDahJ?JBrc^*n$V5H&=4P(xH3vJESJuz-CU zGSuMs?{|i|QG;W7&i3w)CRw}5d-9Q{jA}e zPjShoJQQaJ9YccON)h=|92YysDWYVG6PjK`f)_Bj&GFX&3`q!v03lhx3m6A@&0-bc zEUOBbPF3Dk0n)4LKt8CD=y&k zw+FfS=%cNjt>$YYLFip@8q-Itokb(iS$eqyvZTRzDM_AKvR$+w61D8kQCf9&}mgvdiyHS!@r5 zfzcI6aw=*D?aKk1+UEI!b)86C(+t3Wn#%9fvL>6MuF4E_bZlXmgKHApNE+od&L!(# z8;Z!xjHbn6IZ07BAY+P(^o&;a7*2#t< zU!y;RdYF!!dJ{<7%k%s{IPL@P1(*7qyZAYGsqK&P^ShNc2gQ?hR(yKt7^U{b5&qKd TkJ}s+Pj0f}Q{gL=SV#W`hll-J diff --git a/backend/tests/__pycache__/test_main.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_main.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000000000000000000000000000000000..57e4dd2c4727316ddae87e21e1b71ec8ec51dbad GIT binary patch literal 2160 zcmds2&2QX96!+M(Uhg^|q-i7&64?Wd;sf5cNoXpuEm4I82YTX^i{e_y$D0)-J^H2&9yO@;jZFh= znrC!Wg7@%dZHTPO3lZ$Dg?RllkHvQa-y6hSZ)dy>na86Fk_PYD9?(ZhqV&q%97fP# zZcR~wdc{h|K@=rbiJEGOnrPe0py33wAL2>@=;?(t;Y3St2N5jhbksvEJ){!X3QRAn zRiR0Kn5&;FY@rl?>UEURlJS~p89DP$$N_@yVKc?8j2(i#udMho;1w|6zx4QO{BpF6wx-z>(?4SoV(0kl94fJ6ZeBovam+3SRCc zbkj(y!G9 z6<7MT^fzVto0iG7dtS`V(tddT!)O>o)f|`dIj8LgfkVBBw!?npQjbr8+u7RjgV-N1 zt_L9?7=P1Y-Eb!$8PvHKgj;Ul4BWoQYYuZeo`}F_HnIx%;ou`mL(1o!PDuN1?0CCT z;11jvJRFNnS#>JDV$=|OFhxdLylZ7;wXCd2MFxDotgM$|xkpA@lbexqu3crKVvX@Q zx0~It?={;bYKlfTKi&9bqq*g_x8d|OMTJ;KoxbZ2>}bg6vZ~3WbHHlw z(Z8?!F}v_^v_Cp{?__rQ{>Q(X^QY$GvAKAJm!7NuIWiZYtQ?z5r&v7CbA!~z^cXKa zSeM$#M0}QkrTM7{H^z?zKtsldt^L*!w)b1#+y-{A``zWEcWjUz+y;JZ+NW4N&vS#+ z#`GA>kXs;5CgL*?ThjVe%o~dkQOafd3%1XoB8pY#MsbYj0vPyPjy!m_><%0Tn|fbR zKZZjzHt}k1qw`{pE~oHI9T(1U{#d>LA)I8|@r2xw&zXF@L-ZcOK literal 0 HcmV?d00001 diff --git a/backend/tests/__pycache__/test_nlp.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_nlp.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000000000000000000000000000000000..454d395395e4482b0f6a52a672697362a028cb34 GIT binary patch literal 27018 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 b?#-3{x<0=7{_!6KVK>df0n<*t!mRioqE33c diff --git a/backend/tests/helpers/generators.py b/backend/tests/helpers/generators.py index 25c9469..9ed550d 100644 --- a/backend/tests/helpers/generators.py +++ b/backend/tests/helpers/generators.py @@ -8,13 +8,14 @@ from modules.auth.models import User from modules.auth.security import authenticate_user, create_access_token, create_refresh_token, hash_password from modules.auth.schemas import UserRole from tests.conftest import fake +from typing import Optional # Import Optional -def create_user(db: Session, is_admin: bool = False) -> User: +def create_user(db: Session, is_admin: bool = False, username: Optional[str] = None) -> User: unhashed_password = fake.password() _user = User( name=fake.name(), - username=fake.user_name(), + username=username or fake.user_name(), # Use provided username or generate one hashed_password=hash_password(unhashed_password), uuid=uuid_pkg.uuid4(), role=UserRole.ADMIN if is_admin else UserRole.USER, diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 0000000..3885fd2 --- /dev/null +++ b/backend/tests/test_admin.py @@ -0,0 +1,79 @@ +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from unittest.mock import patch + +from tests.helpers import generators +from modules.auth.models import UserRole + +# Test admin routes require admin privileges + +def test_read_admin_unauthorized(client: TestClient) -> None: + """Test accessing admin route without authentication.""" + response = client.get("/api/admin/") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_read_admin_forbidden(db: Session, client: TestClient) -> None: + """Test accessing admin route as a non-admin user.""" + user, password = generators.create_user(db, is_admin=False) # Use is_admin=False + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + + response = client.get("/api/admin/", headers={"Authorization": f"Bearer {access_token}"}) + assert response.status_code == status.HTTP_403_FORBIDDEN + +def test_read_admin_success(db: Session, client: TestClient) -> None: + """Test accessing admin route as an admin user.""" + admin_user, password = generators.create_user(db, is_admin=True) # Use is_admin=True + login_rsp = generators.login(db, admin_user.username, password) + access_token = login_rsp["access_token"] + + response = client.get("/api/admin/", headers={"Authorization": f"Bearer {access_token}"}) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Admin route"} + +@patch("modules.admin.api.cleardb.delay") # Mock the celery task +def test_clear_db_soft(mock_cleardb_delay, db: Session, client: TestClient) -> None: + """Test soft clearing the database as admin.""" + admin_user, password = generators.create_user(db, is_admin=True) # Use is_admin=True + login_rsp = generators.login(db, admin_user.username, password) + access_token = login_rsp["access_token"] + + response = client.post( + "/api/admin/cleardb", + headers={"Authorization": f"Bearer {access_token}"}, + json={"hard": False} + ) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Clearing database in the background", "hard": False} + mock_cleardb_delay.assert_called_once_with(False) + +@patch("modules.admin.api.cleardb.delay") # Mock the celery task +def test_clear_db_hard(mock_cleardb_delay, db: Session, client: TestClient) -> None: + """Test hard clearing the database as admin.""" + admin_user, password = generators.create_user(db, is_admin=True) # Use is_admin=True + login_rsp = generators.login(db, admin_user.username, password) + access_token = login_rsp["access_token"] + + response = client.post( + "/api/admin/cleardb", + headers={"Authorization": f"Bearer {access_token}"}, + json={"hard": True} + ) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "Clearing database in the background", "hard": True} + mock_cleardb_delay.assert_called_once_with(True) + +def test_clear_db_forbidden(db: Session, client: TestClient) -> None: + """Test clearing the database as a non-admin user.""" + user, password = generators.create_user(db, is_admin=False) # Use is_admin=False + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + + response = client.post( + "/api/admin/cleardb", + headers={"Authorization": f"Bearer {access_token}"}, + json={"hard": False} + ) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 10813d2..22c0115 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -61,8 +61,8 @@ def test_refresh_token(db: Session, client: TestClient) -> None: response = client.post( "/api/auth/refresh", - headers={"Authorization": f"Bearer {access_token}"}, - cookies={"refresh_token": refresh_token}, + headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}, + json={"refresh_token": refresh_token}, ) assert response.status_code == status.HTTP_200_OK @@ -80,8 +80,8 @@ def test_logout(db: Session, client: TestClient) -> None: response = client.post( "/api/auth/logout", - headers={"Authorization": f"Bearer {access_token}"}, - cookies={"refresh_token": refresh_token}, + headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}, + json={"refresh_token": refresh_token}, ) assert response.status_code == status.HTTP_200_OK @@ -98,7 +98,8 @@ def test_logout(db: Session, client: TestClient) -> None: response = client.post( "/api/auth/refresh", - cookies={"refresh_token": refresh_token}, + headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}, + json={"refresh_token": refresh_token}, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED @@ -177,4 +178,53 @@ def test_delete_user(db: Session, client: TestClient) -> None: # Verify that the user is deleted deleted_user = db.query(User).filter(User.username == user.username).first() assert deleted_user is None - \ No newline at end of file + +def test_get_user_forbidden(db: Session, client: TestClient) -> None: + """Test getting another user's profile (should be forbidden).""" + user1, password_user1 = generators.create_user(db, username="user1_get_forbidden") + user2, _ = generators.create_user(db, username="user2_get_forbidden") + + # Log in as user1 + login_rsp = generators.login(db, user1.username, password_user1) + access_token = login_rsp["access_token"] + + # Try to get user2's profile + response = client.get( + f"/api/user/{user2.username}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + +def test_update_user_forbidden(db: Session, client: TestClient) -> None: + """Test updating another user's profile (should be forbidden).""" + user1, password_user1 = generators.create_user(db, username="user1_update_forbidden") + user2, _ = generators.create_user(db, username="user2_update_forbidden") + new_name = fake.name() + + # Log in as user1 + login_rsp = generators.login(db, user1.username, password_user1) + access_token = login_rsp["access_token"] + + # Try to update user2's profile + response = client.patch( + f"/api/user/{user2.username}", + headers={"Authorization": f"Bearer {access_token}"}, + json={"name": new_name}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + +def test_delete_user_forbidden(db: Session, client: TestClient) -> None: + """Test deleting another user's profile (should be forbidden).""" + user1, password_user1 = generators.create_user(db, username="user1_delete_forbidden") + user2, _ = generators.create_user(db, username="user2_delete_forbidden") + + # Log in as user1 + login_rsp = generators.login(db, user1.username, password_user1) + access_token = login_rsp["access_token"] + + # Try to delete user2's profile + response = client.delete( + f"/api/user/{user2.username}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/backend/tests/test_calendar.py b/backend/tests/test_calendar.py index caba7ab..da0eec6 100644 --- a/backend/tests/test_calendar.py +++ b/backend/tests/test_calendar.py @@ -1,44 +1,389 @@ +import pytest +from fastapi import status from fastapi.testclient import TestClient from sqlalchemy.orm import Session +from datetime import datetime, timedelta + from tests.helpers import generators +from modules.calendar.models import CalendarEvent # Assuming model exists +from tests.conftest import fake + +# Helper function to create an event payload +def create_event_payload(start_offset_days=0, end_offset_days=1): + start_time = datetime.utcnow() + timedelta(days=start_offset_days) + end_time = datetime.utcnow() + timedelta(days=end_offset_days) + return { + "title": fake.sentence(nb_words=3), + "description": fake.text(), + "start": start_time.isoformat(), # Rename start_time to start + "end": end_time.isoformat(), # Rename end_time to end + "all_day": fake.boolean(), + } + +# --- Test Create Event --- + +def test_create_event_unauthorized(client: TestClient) -> None: + """Test creating an event without authentication.""" + payload = create_event_payload() + response = client.post("/api/calendar/events", json=payload) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_create_event_success(db: Session, client: TestClient) -> None: + """Test creating a calendar event successfully.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + payload = create_event_payload() + + response = client.post( + "/api/calendar/events", + headers={"Authorization": f"Bearer {access_token}"}, + json=payload + ) + assert response.status_code == status.HTTP_201_CREATED # Change expected status to 201 + data = response.json() + assert data["title"] == payload["title"] + assert data["description"] == payload["description"] + # Remove the '+ "Z"' as the API doesn't add it + assert data["start"] == payload["start"] + assert data["end"] == payload["end"] + assert data["all_day"] == payload["all_day"] + assert "id" in data + assert data["user_id"] == user.id + + # Verify in DB + event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == data["id"]).first() + assert event_in_db is not None + assert event_in_db.user_id == user.id + assert event_in_db.title == payload["title"] + +# --- Test Get Events --- + +def test_get_events_unauthorized(client: TestClient) -> None: + """Test getting events without authentication.""" + response = client.get("/api/calendar/events") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_get_events_success(db: Session, client: TestClient) -> None: + """Test getting all calendar events for a user.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + + # Create a couple of events for the user + payload1 = create_event_payload(0, 1) + client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload1) + payload2 = create_event_payload(2, 3) + client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload2) + + # Create an event for another user (should not be returned) + other_user, other_password = generators.create_user(db) + other_login_rsp = generators.login(db, other_user.username, other_password) + other_access_token = other_login_rsp["access_token"] + other_payload = create_event_payload(4, 5) + client.post("/api/calendar/events", headers={"Authorization": f"Bearer {other_access_token}"}, json=other_payload) -def test_create_event(client: TestClient, db: Session) -> None: - user, unhashed_password = generators.create_user(db) - rsp = generators.login(db, user.username, unhashed_password) - access_token = rsp["access_token"] - refresh_token = rsp["refresh_token"] - - response = client.post("/api/calendar/events", - json={ - "title": "Test Event", - "start_time": "2024-03-20T15:00:00Z" - }, - headers={"Authorization": f"Bearer {access_token}"}, - cookies={"refresh_token": refresh_token}, + response = client.get( + "/api/calendar/events", + headers={"Authorization": f"Bearer {access_token}"} ) - assert response.status_code == 200 - assert response.json()["title"] == "Test Event" + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 2 + assert data[0]["title"] == payload1["title"] + assert data[1]["title"] == payload2["title"] + assert data[0]["user_id"] == user.id + assert data[1]["user_id"] == user.id -def test_get_events(client: TestClient, db: Session) -> None: - user, unhashed_password = generators.create_user(db) - rsp = generators.login(db, user.username, unhashed_password) - access_token = rsp["access_token"] - refresh_token = rsp["refresh_token"] - - # Create an event to retrieve - client.post("/api/calendar/events", - json={ - "title": "Test Event", - "start_time": "2024-03-20T15:00:00Z" - }, + +def test_get_events_filtered(db: Session, client: TestClient) -> None: + """Test getting filtered calendar events for a user.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + + # Create events + payload1 = create_event_payload(0, 1) # Today -> Tomorrow + client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload1) + payload2 = create_event_payload(5, 6) # In 5 days -> In 6 days + client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload2) + payload3 = create_event_payload(10, 11) # In 10 days -> In 11 days + client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload3) + + # Filter for events starting within the next week + start_filter = datetime.utcnow().isoformat() + end_filter = (datetime.utcnow() + timedelta(days=7)).isoformat() + + response = client.get( + "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, - cookies={"refresh_token": refresh_token}, + params={"start": start_filter, "end": end_filter} ) - - response = client.get("/api/calendar/events", + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 2 # Should get event 1 and 2 + assert data[0]["title"] == payload1["title"] + assert data[1]["title"] == payload2["title"] + + # Filter for events starting after 8 days + start_filter_late = (datetime.utcnow() + timedelta(days=8)).isoformat() + response = client.get( + "/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, - cookies={"refresh_token": refresh_token}, + params={"start": start_filter_late} ) - assert response.status_code == 200 - assert len(response.json()) > 0 \ No newline at end of file + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 1 # Should get event 3 + assert data[0]["title"] == payload3["title"] + + +# --- Test Get Event By ID --- + +def test_get_event_by_id_unauthorized(db: Session, client: TestClient) -> None: + """Test getting a specific event without authentication.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + payload = create_event_payload() + create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload) + event_id = create_response.json()["id"] + + response = client.get(f"/api/calendar/events/{event_id}") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_get_event_by_id_success(db: Session, client: TestClient) -> None: + """Test getting a specific event successfully.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + payload = create_event_payload() + create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload) + event_id = create_response.json()["id"] + + response = client.get( + f"/api/calendar/events/{event_id}", + headers={"Authorization": f"Bearer {access_token}"} + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == event_id + assert data["title"] == payload["title"] + assert data["user_id"] == user.id + +def test_get_event_by_id_not_found(db: Session, client: TestClient) -> None: + """Test getting a non-existent event.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + non_existent_id = 99999 + + response = client.get( + f"/api/calendar/events/{non_existent_id}", + headers={"Authorization": f"Bearer {access_token}"} + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + +def test_get_event_by_id_forbidden(db: Session, client: TestClient) -> None: + """Test getting another user's event.""" + user1, password_user1 = generators.create_user(db) + user2, password_user2 = generators.create_user(db) + + # Log in as user1 and create an event + login_rsp1 = generators.login(db, user1.username, password_user1) + access_token1 = login_rsp1["access_token"] + payload = create_event_payload() + create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token1}"}, json=payload) + event_id = create_response.json()["id"] + + # Log in as user2 and try to get user1's event + login_rsp2 = generators.login(db, user2.username, password_user2) + access_token2 = login_rsp2["access_token"] + + response = client.get( + f"/api/calendar/events/{event_id}", + headers={"Authorization": f"Bearer {access_token2}"} + ) + assert response.status_code == status.HTTP_404_NOT_FOUND # Service layer returns 404 if user_id doesn't match + +# --- Test Update Event --- + +def test_update_event_unauthorized(db: Session, client: TestClient) -> None: + """Test updating an event without authentication.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + payload = create_event_payload() + create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload) + event_id = create_response.json()["id"] + update_payload = {"title": "Updated Title"} + + response = client.patch(f"/api/calendar/events/{event_id}", json=update_payload) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_update_event_success(db: Session, client: TestClient) -> None: + """Test updating an event successfully.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + payload = create_event_payload() + create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload) + assert create_response.status_code == status.HTTP_201_CREATED # Ensure creation check uses 201 + event_id = create_response.json()["id"] + + update_payload = { + "title": "Updated Title", + "description": "Updated description.", + "all_day": not payload["all_day"] # Toggle all_day + } + + response = client.patch( + f"/api/calendar/events/{event_id}", + headers={"Authorization": f"Bearer {access_token}"}, + json=update_payload + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == event_id + assert data["title"] == update_payload["title"] + assert data["description"] == update_payload["description"] + assert data["all_day"] == update_payload["all_day"] + assert data["start"] == payload["start"] # Check correct field name 'start' + assert data["user_id"] == user.id + + # Verify in DB + event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() + assert event_in_db is not None + assert event_in_db.title == update_payload["title"] + assert event_in_db.description == update_payload["description"] + assert event_in_db.all_day == update_payload["all_day"] + +def test_update_event_not_found(db: Session, client: TestClient) -> None: + """Test updating a non-existent event.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + non_existent_id = 99999 + update_payload = {"title": "Updated Title"} + + response = client.patch( + f"/api/calendar/events/{non_existent_id}", + headers={"Authorization": f"Bearer {access_token}"}, + json=update_payload + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + +def test_update_event_forbidden(db: Session, client: TestClient) -> None: + """Test updating another user's event.""" + user1, password_user1 = generators.create_user(db) + user2, password_user2 = generators.create_user(db) + + # Log in as user1 and create an event + login_rsp1 = generators.login(db, user1.username, password_user1) + access_token1 = login_rsp1["access_token"] + payload = create_event_payload() + create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token1}"}, json=payload) + event_id = create_response.json()["id"] + + # Log in as user2 and try to update user1's event + login_rsp2 = generators.login(db, user2.username, password_user2) + access_token2 = login_rsp2["access_token"] + update_payload = {"title": "Updated by User 2"} + + response = client.patch( + f"/api/calendar/events/{event_id}", + headers={"Authorization": f"Bearer {access_token2}"}, + json=update_payload + ) + assert response.status_code == status.HTTP_404_NOT_FOUND # Service layer returns 404 if user_id doesn't match + +# --- Test Delete Event --- + +def test_delete_event_unauthorized(db: Session, client: TestClient) -> None: + """Test deleting an event without authentication.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + payload = create_event_payload() + create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload) + event_id = create_response.json()["id"] + + response = client.delete(f"/api/calendar/events/{event_id}") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_delete_event_success(db: Session, client: TestClient) -> None: + """Test deleting an event successfully.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + payload = create_event_payload() + create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload) + assert create_response.status_code == status.HTTP_201_CREATED # Ensure creation check uses 201 + event_id = create_response.json()["id"] + + # Verify event exists before delete + event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() + assert event_in_db is not None + + response = client.delete( + f"/api/calendar/events/{event_id}", + headers={"Authorization": f"Bearer {access_token}"} + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify event is deleted from DB + event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() + assert event_in_db is None + + # Try getting the deleted event (should be 404) + get_response = client.get( + f"/api/calendar/events/{event_id}", + headers={"Authorization": f"Bearer {access_token}"} + ) + assert get_response.status_code == status.HTTP_404_NOT_FOUND + + +def test_delete_event_not_found(db: Session, client: TestClient) -> None: + """Test deleting a non-existent event.""" + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + access_token = login_rsp["access_token"] + non_existent_id = 99999 + + response = client.delete( + f"/api/calendar/events/{non_existent_id}", + headers={"Authorization": f"Bearer {access_token}"} + ) + # The service layer raises NotFound, which should result in 404 + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_delete_event_forbidden(db: Session, client: TestClient) -> None: + """Test deleting another user's event.""" + user1, password_user1 = generators.create_user(db) + user2, password_user2 = generators.create_user(db) + + # Log in as user1 and create an event + login_rsp1 = generators.login(db, user1.username, password_user1) + access_token1 = login_rsp1["access_token"] + payload = create_event_payload() + create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token1}"}, json=payload) + event_id = create_response.json()["id"] + + # Log in as user2 and try to delete user1's event + login_rsp2 = generators.login(db, user2.username, password_user2) + access_token2 = login_rsp2["access_token"] + + response = client.delete( + f"/api/calendar/events/{event_id}", + headers={"Authorization": f"Bearer {access_token2}"} + ) + # The service layer raises NotFound if user_id doesn't match, resulting in 404 + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Verify event still exists for user1 + event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first() + assert event_in_db is not None + assert event_in_db.user_id == user1.id + diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py new file mode 100644 index 0000000..2401a93 --- /dev/null +++ b/backend/tests/test_main.py @@ -0,0 +1,10 @@ +import pytest +from fastapi.testclient import TestClient + +# No database needed for this simple test + +def test_health_check(client: TestClient): + """Test the health check endpoint.""" + response = client.get("/api/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/backend/tests/test_nlp.py b/backend/tests/test_nlp.py new file mode 100644 index 0000000..28f39e3 --- /dev/null +++ b/backend/tests/test_nlp.py @@ -0,0 +1,218 @@ +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from unittest.mock import patch, MagicMock +from datetime import datetime + +from tests.helpers import generators +from modules.nlp.schemas import ProcessCommandRequest, ProcessCommandResponse +from modules.nlp.models import MessageSender, ChatMessage # Import necessary models/enums + +# --- Mocks --- +# Mock the external AI call and internal service functions +@pytest.fixture(autouse=True) +def mock_nlp_services(): + with patch("modules.nlp.api.process_request") as mock_process, \ + patch("modules.nlp.api.ask_ai") as mock_ask, \ + patch("modules.nlp.api.save_chat_message") as mock_save, \ + patch("modules.nlp.api.get_chat_history") as mock_get_history, \ + patch("modules.nlp.api.create_calendar_event") as mock_create_event, \ + patch("modules.nlp.api.get_calendar_events") as mock_get_events, \ + patch("modules.nlp.api.update_calendar_event") as mock_update_event, \ + patch("modules.nlp.api.delete_calendar_event") as mock_delete_event, \ + patch("modules.nlp.api.todo_service.create_todo") as mock_create_todo, \ + patch("modules.nlp.api.todo_service.get_todos") as mock_get_todos, \ + patch("modules.nlp.api.todo_service.update_todo") as mock_update_todo, \ + patch("modules.nlp.api.todo_service.delete_todo") as mock_delete_todo: + mocks = { + "process_request": mock_process, + "ask_ai": mock_ask, + "save_chat_message": mock_save, + "get_chat_history": mock_get_history, + "create_calendar_event": mock_create_event, + "get_calendar_events": mock_get_events, + "update_calendar_event": mock_update_event, + "delete_calendar_event": mock_delete_event, + "create_todo": mock_create_todo, + "get_todos": mock_get_todos, + "update_todo": mock_update_todo, + "delete_todo": mock_delete_todo, + } + yield mocks + +# --- Helper Function --- +def _login_user(db: Session, client: TestClient): + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + return user, login_rsp["access_token"], login_rsp["refresh_token"] + +# --- Tests for /process-command --- + +def test_process_command_ask_ai(client: TestClient, db: Session, mock_nlp_services): + user, access_token, refresh_token = _login_user(db, client) + user_input = "What is the capital of France?" + mock_nlp_services["process_request"].return_value = { + "intent": "ask_ai", + "params": {"request": user_input}, + "response_text": "Let me check that for you." + } + mock_nlp_services["ask_ai"].return_value = "The capital of France is Paris." + + response = client.post( + "/api/nlp/process-command", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + json={"user_input": user_input} + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == ProcessCommandResponse(responses=["Let me check that for you.", "The capital of France is Paris."]).model_dump() + # Verify save calls: user message, initial AI response, final AI answer + assert mock_nlp_services["save_chat_message"].call_count == 3 + mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.USER, text=user_input) + mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.AI, text="Let me check that for you.") + mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.AI, text="The capital of France is Paris.") + mock_nlp_services["ask_ai"].assert_called_once_with(request=user_input) + +def test_process_command_get_calendar(client: TestClient, db: Session, mock_nlp_services): + user, access_token, refresh_token = _login_user(db, client) + user_input = "What are my events today?" + mock_nlp_services["process_request"].return_value = { + "intent": "get_calendar_events", + "params": {"start": "2024-01-01T00:00:00Z", "end": "2024-01-01T23:59:59Z"}, # Example params + "response_text": "Okay, fetching your events." + } + # Mock the actual event model returned by the service + mock_event = MagicMock() + mock_event.title = "Team Meeting" + mock_event.start = datetime(2024, 1, 1, 10, 0, 0) + mock_event.end = datetime(2024, 1, 1, 11, 0, 0) + mock_nlp_services["get_calendar_events"].return_value = [mock_event] + + response = client.post( + "/api/nlp/process-command", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + json={"user_input": user_input} + ) + + assert response.status_code == status.HTTP_200_OK + expected_responses = [ + "Okay, fetching your events.", + "Here are the events:", + "- Team Meeting (2024-01-01 10:00 - 11:00)" + ] + assert response.json() == ProcessCommandResponse(responses=expected_responses).model_dump() + assert mock_nlp_services["save_chat_message"].call_count == 4 # User, Initial AI, Header, Event + mock_nlp_services["get_calendar_events"].assert_called_once() + +def test_process_command_add_todo(client: TestClient, db: Session, mock_nlp_services): + user, access_token, refresh_token = _login_user(db, client) + user_input = "Add buy milk to my list" + mock_nlp_services["process_request"].return_value = { + "intent": "add_todo", + "params": {"task": "buy milk"}, + "response_text": "Adding it now." + } + # Mock the actual Todo model returned by the service + mock_todo = MagicMock() + mock_todo.task = "buy milk" + mock_todo.id = 1 + mock_nlp_services["create_todo"].return_value = mock_todo + + response = client.post( + "/api/nlp/process-command", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + json={"user_input": user_input} + ) + + assert response.status_code == status.HTTP_200_OK + expected_responses = ["Adding it now.", "Added TODO: 'buy milk' (ID: 1)."] + assert response.json() == ProcessCommandResponse(responses=expected_responses).model_dump() + assert mock_nlp_services["save_chat_message"].call_count == 3 # User, Initial AI, Confirmation AI + mock_nlp_services["create_todo"].assert_called_once() + +def test_process_command_clarification(client: TestClient, db: Session, mock_nlp_services): + user, access_token, refresh_token = _login_user(db, client) + user_input = "Delete the event" + clarification_text = "Which event do you mean? Please provide the ID." + mock_nlp_services["process_request"].return_value = { + "intent": "clarification_needed", + "params": {"request": user_input}, + "response_text": clarification_text + } + + response = client.post( + "/api/nlp/process-command", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + json={"user_input": user_input} + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == ProcessCommandResponse(responses=[clarification_text]).model_dump() + # Verify save calls: user message, clarification AI response + assert mock_nlp_services["save_chat_message"].call_count == 2 + mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.USER, text=user_input) + mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.AI, text=clarification_text) + # Ensure no action services were called + mock_nlp_services["delete_calendar_event"].assert_not_called() + +def test_process_command_error_intent(client: TestClient, db: Session, mock_nlp_services): + user, access_token, refresh_token = _login_user(db, client) + user_input = "Gibberish request" + error_text = "Sorry, I didn't understand that." + mock_nlp_services["process_request"].return_value = { + "intent": "error", + "params": {}, + "response_text": error_text + } + + response = client.post( + "/api/nlp/process-command", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + json={"user_input": user_input} + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == ProcessCommandResponse(responses=[error_text]).model_dump() + # Verify save calls: user message, error AI response + assert mock_nlp_services["save_chat_message"].call_count == 2 + mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.USER, text=user_input) + mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.AI, text=error_text) + +# --- Tests for /history --- + +def test_get_history(client: TestClient, db: Session, mock_nlp_services): + user, access_token, refresh_token = _login_user(db, client) + + # Mock the history data returned by the service + mock_history = [ + ChatMessage(id=1, user_id=user.id, sender=MessageSender.USER, text="Hello", timestamp=datetime.now()), + ChatMessage(id=2, user_id=user.id, sender=MessageSender.AI, text="Hi there!", timestamp=datetime.now()) + ] + mock_nlp_services["get_chat_history"].return_value = mock_history + + response = client.get( + "/api/nlp/history", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token} + ) + + assert response.status_code == status.HTTP_200_OK + # We need to compare JSON representations as datetime objects might differ slightly + response_data = response.json() + assert len(response_data) == 2 + assert response_data[0]["text"] == "Hello" + assert response_data[1]["text"] == "Hi there!" + mock_nlp_services["get_chat_history"].assert_called_once_with(db, user_id=user.id, limit=50) + +def test_get_history_unauthorized(client: TestClient): + response = client.get("/api/nlp/history") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +# Add more tests for other intents (update/delete calendar/todo, unknown intent, etc.) +# Add tests for error handling within the API endpoint (e.g., missing IDs for update/delete) diff --git a/backend/tests/test_todo.py b/backend/tests/test_todo.py new file mode 100644 index 0000000..573ea76 --- /dev/null +++ b/backend/tests/test_todo.py @@ -0,0 +1,210 @@ +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from datetime import date + +from tests.helpers import generators +from modules.todo import schemas # Import schemas + +# Helper Function +def _login_user(db: Session, client: TestClient): + user, password = generators.create_user(db) + login_rsp = generators.login(db, user.username, password) + return user, login_rsp["access_token"], login_rsp["refresh_token"] + +# --- Test CRUD Operations --- + +def test_create_todo(client: TestClient, db: Session): + user, access_token, refresh_token = _login_user(db, client) + today_date = date.today() + # Format the date string to match the expected response format "YYYY-MM-DDTHH:MM:SS" + todo_data = { + "task": "Test TODO", + "date": f"{today_date.isoformat()}T00:00:00", + "remind": True + } + + response = client.post( + "/api/todos/", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + json=todo_data + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["task"] == todo_data["task"] + assert data["date"] == todo_data["date"] + assert data["remind"] == todo_data["remind"] + assert data["complete"] is False # Default + assert "id" in data + assert data["owner_id"] == user.id + +def test_read_todos(client: TestClient, db: Session): + user, access_token, refresh_token = _login_user(db, client) + # Create some todos for the user + client.post("/api/todos/", headers={"Authorization": f"Bearer {access_token}"}, cookies={"refresh_token": refresh_token}, json={"task": "Todo 1"}) + client.post("/api/todos/", headers={"Authorization": f"Bearer {access_token}"}, cookies={"refresh_token": refresh_token}, json={"task": "Todo 2"}) + + # Create a todo for another user + other_user, other_password = generators.create_user(db) + other_login_rsp = generators.login(db, other_user.username, other_password) + other_access_token = other_login_rsp["access_token"] + other_refresh_token = other_login_rsp["refresh_token"] + client.post("/api/todos/", headers={"Authorization": f"Bearer {other_access_token}"}, cookies={"refresh_token": other_refresh_token}, json={"task": "Other User Todo"}) + + + response = client.get( + "/api/todos/", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 2 # Should only get todos for the logged-in user + assert data[0]["task"] == "Todo 1" + assert data[1]["task"] == "Todo 2" + +def test_read_single_todo(client: TestClient, db: Session): + user, access_token, refresh_token = _login_user(db, client) + create_response = client.post( + "/api/todos/", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + json={"task": "Specific Todo"} + ) + todo_id = create_response.json()["id"] + + response = client.get( + f"/api/todos/{todo_id}", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == todo_id + assert data["task"] == "Specific Todo" + assert data["owner_id"] == user.id + +def test_read_single_todo_not_found(client: TestClient, db: Session): + user, access_token, refresh_token = _login_user(db, client) + response = client.get( + "/api/todos/9999", # Non-existent ID + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token} + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + +def test_read_single_todo_forbidden(client: TestClient, db: Session): + user, access_token, refresh_token = _login_user(db, client) + + # Create a todo for another user + other_user, other_password = generators.create_user(db) + other_login_rsp = generators.login(db, other_user.username, other_password) + other_access_token = other_login_rsp["access_token"] + other_refresh_token = other_login_rsp["refresh_token"] + other_create_response = client.post("/api/todos/", headers={"Authorization": f"Bearer {other_access_token}"}, cookies={"refresh_token": other_refresh_token}, json={"task": "Other User Todo"}) + other_todo_id = other_create_response.json()["id"] + + # Try to access the other user's todo + response = client.get( + f"/api/todos/{other_todo_id}", + headers={"Authorization": f"Bearer {access_token}"}, # Using the first user's token + cookies={"refresh_token": refresh_token} + ) + assert response.status_code == status.HTTP_404_NOT_FOUND # Service raises 404 if not found for *this* user + +def test_update_todo(client: TestClient, db: Session): + user, access_token, refresh_token = _login_user(db, client) + create_response = client.post( + "/api/todos/", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + json={"task": "Update Me"} + ) + todo_id = create_response.json()["id"] + + update_data = {"task": "Updated Task", "complete": True} + response = client.put( + f"/api/todos/{todo_id}", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + json=update_data + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == todo_id + assert data["task"] == update_data["task"] + assert data["complete"] == update_data["complete"] + assert data["owner_id"] == user.id + + # Verify update by reading again + get_response = client.get( + f"/api/todos/{todo_id}", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token} + ) + assert get_response.json()["task"] == update_data["task"] + assert get_response.json()["complete"] == update_data["complete"] + + +def test_update_todo_not_found(client: TestClient, db: Session): + user, access_token, refresh_token = _login_user(db, client) + update_data = {"task": "Updated Task", "complete": True} + response = client.put( + "/api/todos/9999", # Non-existent ID + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + json=update_data + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + +def test_delete_todo(client: TestClient, db: Session): + user, access_token, refresh_token = _login_user(db, client) + create_response = client.post( + "/api/todos/", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + json={"task": "Delete Me"} + ) + todo_id = create_response.json()["id"] + + response = client.delete( + f"/api/todos/{todo_id}", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token} + ) + + assert response.status_code == status.HTTP_200_OK # Delete returns the deleted item + assert response.json()["id"] == todo_id + + # Verify deletion by trying to read + get_response = client.get( + f"/api/todos/{todo_id}", + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token} + ) + assert get_response.status_code == status.HTTP_404_NOT_FOUND + +def test_delete_todo_not_found(client: TestClient, db: Session): + user, access_token, refresh_token = _login_user(db, client) + response = client.delete( + "/api/todos/9999", # Non-existent ID + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token} + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + +# --- Test Authentication/Authorization --- + +def test_create_todo_unauthorized(client: TestClient): + response = client.post("/api/todos/", json={"task": "No Auth"}) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_read_todos_unauthorized(client: TestClient): + response = client.get("/api/todos/") + assert response.status_code == status.HTTP_401_UNAUTHORIZED