From e7f53b61a6c8278b3798fae27ead8af20c7f5cbb Mon Sep 17 00:00:00 2001 From: zargess Date: Mon, 22 Dec 2025 13:21:41 +0100 Subject: [PATCH 1/7] Update gitignore Git now ignores the build folder in the root directory of the project --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 373dd783..8dfbde68 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,5 @@ users *.config *.fagi .gradle/* -*/build/* +**/build/* buildSrc/.gradle \ No newline at end of file From e66efd28d2ea788cf27906139d11ae39b971aceb Mon Sep 17 00:00:00 2001 From: zargess Date: Mon, 22 Dec 2025 13:33:04 +0100 Subject: [PATCH 2/7] Upgraded gradle and dependencies ## Gradle upgrade Upgraded gradle to 8.14.3. Pitest plugin blocks upgrade to gradle 9. ## Versions catalog Switched to using gradle versions catalog to easily enforce the same versions of libraries across the project. ## Upgraded dependencies and plugins Upgraded all dependencies and plugins to the latest stable versions available. ### junit-platform junit platform now uses the same version as the rest of our junit dependencies. --- build.gradle.kts | 2 +- buildSrc/build.gradle.kts | 2 +- fagiClient/build.gradle.kts | 21 ++++---------- fagiServer/build.gradle.kts | 6 ++-- gradle/libs.versions.toml | 35 +++++++++++++++++++++++ gradle/wrapper/gradle-wrapper.jar | Bin 43453 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 10 ++++--- gradlew.bat | 6 ++-- shared/build.gradle.kts | 11 +++---- 10 files changed, 59 insertions(+), 36 deletions(-) create mode 100644 gradle/libs.versions.toml diff --git a/build.gradle.kts b/build.gradle.kts index 10ddbdc7..b4f59a82 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { - id("info.solidsoft.pitest") version "1.15.0" apply false + alias(libs.plugins.pitest) apply false } allprojects { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index b370c7da..450a5343 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("org.jetbrains.kotlin.jvm") version "1.9.24" + id("org.jetbrains.kotlin.jvm") version "2.3.0" } repositories { diff --git a/fagiClient/build.gradle.kts b/fagiClient/build.gradle.kts index 25a1e369..fc5a93e9 100644 --- a/fagiClient/build.gradle.kts +++ b/fagiClient/build.gradle.kts @@ -1,19 +1,12 @@ plugins { id("application") - id("org.openjfx.javafxplugin") version "0.1.0" + alias(libs.plugins.javafx) } application { mainClass.set("com.fagi.main.FagiApp") } -repositories { - mavenCentral() - flatDir { - dirs("libs") - } -} - tasks.test { useJUnitPlatform() } @@ -25,12 +18,8 @@ javafx { dependencies { implementation(project(":shared")) - testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.2") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.2") - testImplementation("org.mockito:mockito-core:5.12.0") - testImplementation("org.hamcrest:hamcrest:2.2") - testImplementation("org.testfx:testfx-core:4.0.18") - testImplementation("org.testfx:testfx-junit5:4.0.18") - testImplementation("org.openjfx:javafx-swing:21.0.3") - testImplementation("org.testfx:openjfx-monocle:21.0.2") + testImplementation(libs.bundles.junit.base) + testImplementation(libs.bundles.mockito) + testImplementation(libs.hamcrest) + testImplementation(libs.bundles.testfx) } diff --git a/fagiServer/build.gradle.kts b/fagiServer/build.gradle.kts index e9df82bb..22655d3c 100644 --- a/fagiServer/build.gradle.kts +++ b/fagiServer/build.gradle.kts @@ -12,8 +12,6 @@ tasks.test { dependencies { implementation(project(":shared")) - testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.2") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.2") - testImplementation("org.mockito:mockito-core:5.12.0") - testImplementation("org.mockito:mockito-junit-jupiter:5.12.0") + testImplementation(libs.bundles.junit.base) + testImplementation(libs.bundles.mockito) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..de4cdf90 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,35 @@ +[versions] +junit = "6.1.0-M1" +mockito = "5.21.0" +testfx = "4.0.18" + +[libraries] +# --------- Prod dependenices --------- +gson = { module = "com.google.code.gson:gson", version = "2.13.2" } +# ------------------------------------- + +# --------- Test dependenices --------- +hamcrest = { module = "org.hamcrest:hamcrest", version = "3.0" } + +junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } +junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } +junit-platform = { module = "org.junit.platform:junit-platform-suite", version.ref = "junit" } + +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } + +openjfx-javafx-swing = { module = "org.openjfx:javafx-swing", version = "21.0.9" } + +testfx-core = { module = "org.testfx:testfx-core", version.ref = "testfx" } +testfx-junit5 = { module = "org.testfx:testfx-junit5", version.ref = "testfx" } +testfx-openjfx-monocle = { module = "org.testfx:openjfx-monocle", version = "21.0.2" } +# ------------------------------------- + +[bundles] +junit-base = ["junit-jupiter-api", "junit-jupiter-engine"] +mockito = ["mockito-core", "mockito-junit-jupiter"] +testfx = ["openjfx-javafx-swing", "testfx-core", "testfx-junit5", "testfx-openjfx-monocle"] + +[plugins] +javafx = { id = "org.openjfx.javafxplugin", version = "0.1.0" } +pitest = { id = "info.solidsoft.pitest", version = "1.15.0" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e6441136f3d4ba8a0da8d277868979cfbc8ad796..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch delta 35073 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 delta 34912 zcmXVXV_cp8|NoX2PS(k*W!qS`-LmbLJ4+|?WLwM2u9I7~ZDVoOf1mH~f9JY&-MAjt z`-P|8ck?ivvoN)GXiB$=zsD5hnV6?h<(cRweoy{VW1ZvJ+Q0eDG%P!=IL;u;_!0R8 zY@V`Lq(|3+PgSy4L?41rg@;pwckO!Z`tgH`{3k?*I$@!&A3l5#`2e{VAckO|OMx01 zs~QdASV-N}R?pQ=O{yqlrqz|1yp(^RK)Bhkwq`;Yh)md4RrtR%sNbw?F7+wVN@9oT5^KvyxHCChVwDz29-_(~6`YI}kOI zb^sOR2x~T#ZdIJ>Rf@`fWMMck8Z~Fk7!ymA-q=^Hp5eZ$X)}%69EWv#a)HMQBo+#f z36F86&q=PH!h1hfL>Ol{cXt`zy7GFq%Eq79O{IA-u!cH*(wj1wN}D2M4WT6o(qxrW zEB}r}@-+r4&wIr;xO0(AI@=cYWb?m21~K;0A^-T{gEQnxfCN&@N(#Zq#RXZY87O0m z;t0Wp7M~;I&<5qU1T+?pjfUye_TixR_f>$?rT1}+*6u;9Gn0cXM{`4grB6(W zyBDpHwv$&%UIzt(jZMh^e3jZ{I@kE301olpI{yj0+;ZWogmFjno1+v zMW;sMFf7sR(_fhVjl~QhEC!kN?S1GnQ8&fuPw9z{5eDbyAAsT&CyjpUf=RK)X*YhW zwf>HLeXJxlm0mFjo>lB@ni;CUkg)*JRligsG*5>@wN*UJvbS&X^}x zn@^UJmJ90QY)d4OLkji-vg;l*>VWz+eRS?0G0Bg!HhZc?2Wz}S3kMg^_@+65nA?uo zkBwh=aDQVGH8XVK>zh0u{gJbev&iTnS1h3p(pF$?`aC^rhJj2lK`5&HHV#_?kJb zGMSi_SJ(*5xg|k>>Dvgt0#5hN#b8)>x5&pj4Wy_c7=p-XQ=>p*vRykohWoq+vj1uk znu?X~2=n2?uaB_*+Lr;+&434q#3lhbD9@_k1Te#nwy}MM^TTHt=B7p23Hvw*C##@< z$6AnfJ+Ri~X^`J(;3$v;d?J5C5U~zQwBA9#k|t1Y#>7ZrY#I@2J`|kfQ=Sxhc*rH| z{varkusu6HJ$Ca6x^v$ZA6sX;#AVi73(ebp61*3)LCF6yToc0LMMm{D%k+S_eJ<3CTZgjVEpgE=i5mX z0o|kFlPT7$0gM?NfN_Wk=T=zCXFhtz_fJrXuKFQ#uaUzUCWj%}$pz$g05t#ar{-1o z#ZYh6o&A&s>>NA5>#m&gf?X>M)bj>Q7YY}AR8nPC<0CJ`QolY!M*@PhNF4%4$5nFf z4{VxA-;8{~$A&>%Yo@~y4|O}IqYemSgP7Sy?d}}+e`ng%{?_hDUhCm`I`hP=rda|n zVWx~(i&}Q|fj^k+l$Y30zv6ME&AX7HTjy~frLaX)QgCMmQq3_qKEcRyY7nk_fa}Z$ ztrwMjNeJ|A@3=y7o^6LMBj@LkTyHm7pK(Vxq%M=uXr;M7{wWsrG~I1ki5OQ6#92Ih%Quj|8Z|qUzyy6 zUf%s*-I*73e%AX}cTI5r+ZsgVR1jr6I*hnu%*rSWqzs(T0KD7A4U}76 z)lH{eBF=pRy0q*o<*iM4@ojv65`y{#TKm=!5+7PwC>z)to^he4BI9`z60IYcFC8XC zZ<65C;OV<=0*{u4*i@nn?J4m6_p_jauY-;RSof^%yxer|uPQvyzOCP1x_-}6H;)~6 zkQH$^6A(lu&B^q)5vwSypjGu5P`Y#UdzM%Uhuh>vlisoS7c?a}|1hah-vo_i`e5;! z93hb``au;ow+t;(wB3-=ww(pgb`ZrEODvFvfEiQvXaSX6+A0ooWdEx3u-oBf9V((3iwRO z7r|AqsNjl$(oTUVvOf^E%G%WX=xJnm>@^c!%RBGy7j<>%w26$G5`?s89=$6leu-z; zm&YocPl2@2EDw6AVuSU&r>cR{&34@7`cLYzqnX)TU_5wibwZ+NC5dMyxz3f!>0(Y zJDdZUg*VS5udu>$bd~P>Zq^r)bO{ndzlaMiO5{7vEWb3Jf#FOpb7ZDmmnP?5x?`TX z@_zlHn)+{T;BtNeJ1Kdp2+u!?dDx4`{9omcB_-%HYs2n5W-t74WV76()dbBN+P)HN zEpCJy82#5rQM+vTjIbX*7<~F)AB_%L*_LL*fW-7b@ATWT1AoUpajnr9aJ19 zmY}jSdf+bZ;V~9%$rJ-wJ3!DTQ3``rU@M~E-kH$kdWfBiS8QL&(56OM&g*O73qNi( zRjq8{%`~n?-iv!fKL>JDO7S4!aujA}t+u6;A0sxCv_hy~Y2Pbe53I*A1qHMYgSCj0z6O zJ!z}o>nI#-@4ZvRP|M!GqkTNYb7Y)$DPWBF3NCjNU-395FoDOuM6T+OSEwNQn3C`D z-I}Tw$^1)2!XX+o@sZp^B4*!UJ=|lZi63u~M4Q%rQE`2}*SW$b)?||O1ay`#&Xjc! z0RB3AaS%X&szV$SLIsGT@24^$5Z8p%ECKsnE92`h{xp^i(i3o%;W{mjAQmWf(6O8A zf7uXY$J^4o{w}0hV)1am8s1awoz0g%hOx4-7 zx8o@8k%dNJ(lA#*fC+}@0ENA#RLfdZB|fY9dXBb;(hk%{m~8J)QQ7CO5zQ4|)Jo4g z67cMld~VvYe6F!2OjfYz?+gy}S~<7gU@;?FfiET@6~z&q*ec+5vd;KI!tU4``&reW zL3}KkDT;2%n{ph5*uxMj0bNmy2YRohzP+3!P=Z6JA*Crjvb+#p4RTQ=sJAbk@>dP^ zV+h!#Ct4IB`es)P;U!P5lzZCHBH#Q(kD*pgWrlx&qj1p`4KY(+c*Kf7$j5nW^lOB#@PafVap`&1;j9^+4;EDO%G9G4gK zBzrL7D#M1;*$YefD2I-+LH{qgzvY8#|K=-X`LN578mTYqDhU}$>9W&VOs z*wW$@o?Vfqr4R0v4Yo_zlb?HKOFS zU@WY7^A8Y{P)qU9gAz52zB8JHL`Ef!)aK7P)8dct2GxC*y2eQV4gSRoLzW*ovb>hR zb0w+7w?v6Q5x1@S@t%$TP0Wiu2czDS*s8^HFl3HOkm{zwCL7#4wWP6AyUGp_WB8t8 zon>`pPm(j}2I7<SUzI=fltEbSR`iSoE1*F3pH4`ax^yEo<-pi;Os;iXcNrWfCGP^Jmp935cN;!T8bve@Qljm z>3ySDAULgN1!F~X7`sAjokd_;kBL99gBC2yjO+ zEqO##8mjsq`|9xpkae&q&F=J#A}#1%b%i3jK-lptc_O$uVki1KJ?Y=ulf*D$sa)HC z=vNki?1aP~%#31<#s+6US0>wX5}nI zhec(KhqxFhhq%8hS?5p|OZ02EJsNPTf!r5KKQB>C#3||j4cr3JZ%iiKUXDCHr!!{g z=xPxc@U28V8&DpX-UCYz*k~2e)q?lRg<{o%1r;+U)q^{v&abJ9&nc6a32ft(Yk}`j ztiQP@yEKf@Nu3F;yo9O})Roh9P08j7@%ftn7U1y;`mard4+5 zB62wpg$Py_YvQ!PE2HpuC}3el-F3g{*&a z3q{eLy6Xz|F+aMrn8R8IW2NZu{tgsyc(>*TdV79@?V$jG(O+Iz2rnDBc|1cK8gR$Y zthvVTI;(eYhOdjapHe=9KI`|2i;{VIfvnR6`qof=4a=(BTZkev78+6GJW**Z!|yvS zes)T%U573C~Hm`&XJzE=2t7tFIZM`!^r^&z;W?dOj-N+a10^>wV(l~2naa?s; zTxU{z;Go|Ve!vUjUrZ$B#mWH)NSdxi;dWa-@w)-$wBOpo`DEG<;C#W||W}&@z>C`*j9V|`ai)z*2PG`TZt6T{a zj!#m3`Vz5R9wJkNMsJ1`fSCS2mHnizWDT!G0Ukp$%*_^X1=k=%mmO$^_0_d|kc8ek4_DZwomL(>GGtfEB)Wy&cfZ@9-T|hAq&fx;XR$$_yl6iogcR{u zm9g)axS6=_IL4=wQXf|EkzO68$Ms4*JXAt8gFxLCibt^C#C|I|v|U{%A;+NaBX-Yn z`HAmP*x5Ux@@Wkpxest$F~K8v0wlb9$3gHoPU(RMt+!BfjH?`8>KMK|!{28+fAk%6 zWdfyaD;Dr~`aJHn0}HIf^Y9*keGvm6!t?o%;je)wm`Dm$fN?YtdPI7S=Y23+15L{J zr;n3MYg`<50nW^`BM$&M(+PQ7@p7Lvn(kE`cmoNS7UkQmfvXQBs_unhdfM){k`Ho! zHL0#a6}Uzs=(bu;jnBAu>}%LzU3+{sDa6~)q_|pW1~*Is5J(~!lWvX(NpK_$=3Rbn zej|)%uR0imC;D5qF7p}kdg(-e{8#o!D_}?Fa<&{!5#8^b(dQl40ES%O_S(k8Z$?Hs z;~ee=^2*5S#A*gzEJgBkXyn*|;BBH97OOmvaZ>&U&RfU0P(?jgLPyFzybR2)7wG`d zkkwi) zJ^sn7D-;I;%VS+>JLjS6a2bmmL^z^IZTokqBEWpG=9{ zZ@<^lIYqt3hPZgAFLVv6uGt}XhW&^JN!ZUQ|IO5fq;G|b|H@nr{(q!`hDI8ss7%C$ zL2}q02v(8fb2+LAD>BvnEL8L(UXN0um^QCuG@s}4!hCn@Pqn>MNXS;$oza~}dDz>J zx3WkVLJ22a;m4TGOz)iZO;Era%n#Tl)2s7~3%B<{6mR!X`g^oa>z#8i)szD%MBe?uxDud2It3SKV>?7XSimsnk#5p|TaeZ7of*wH>E{djABdP7#qXq- z7iLK+F>>2{EYrg>)K^JAP;>L@gIShuGpaElqp)%cGY2UGfX1E;7jaP6|2dI@cYG%4 zr`K1dRDGg3CuY~h+s&b2*C>xNR_n>ftWSwQDO(V&fXn=Iz`58^tosmz)h73w%~rVOFitWa9sSsrnbp|iY8z20EdnnHIxEX6||k-KWaxqmyo?2Yd?Cu$q4)Qn8~hf0=Lw#TAuOs(*CwL085Qn9qZxg=)ntN*hVHrYCF3cuI2CJk7zS2a%yTNifAL{2M>vhQxo?2 zfu8%hd1$q{Sf0+SPq8pOTIzC&9%Ju9Rc1U9&yjGazlHEDaxY|nnS7rATYCW_NA&U? zN!7-zF#DXu0}k4pjN05yu#>x8o#Jx7|Fk=%OR((ti%UVKWQNH>+JhH#ziW1hD=rk* zD#1j?WuGxd-8VqG@n_Lqj^i=VBOg@GLePo0oHX9P*e7qBzIs1lzyp;}L3tP1 zl5;OiHG&-flQ;rYznH%~hz>fuJ!n*H#O)3NM3`3Z9H|VFfS-_xHRCuLjoIS9wT!F0 zJ-kV3w>7EguDzoBPxW>Rra0#+Y?;Woi7qJ1kpxTad?O?^=1cG@GeNtRZRi8_l-1CS z`(#oF<;VYR(l(gHIYH$y2=rj5m3QL{HQgbW9O!TU*jGj!bFazIL?MYnJEvELf}=I5 zTA6EhkHVTa0U#laMQ6!wT;4Tm4_gN$lp?l~w37UJeMInp}P>2%3b^Pv_E1wcwh zI$`G-I~h!*k^k!)POFjjRQMq+MiE@Woq$h3Dt8A%*8xj1q#x?x%D+o3`s*)JOj2oD7-R4Z*QKknE3S9x z8yA8NsVl&>T`a;qPP9b7l{gF&2x9t5iVUdV-yOC12zJnqe5#5wx0so2I)@8xb$uPG zNmv=X)TjpHG(H!$6Xp>)*S}r538R99Y{Pofv}pAFlUK;xi{E43^->z1srWR=J$8N! z4jRu;EAiLG9R$5#{gR){5?o^W^!t140^f=vCVSs@vK7#`-fv`P*WV|>nX610pK08< z>r#{r)fR?2pNG}8o)?uvX#UJI)YM5CG@0E8s1lEV`rom|kBmf={%h!o|26a=lNJbX z6gkBS7e{-p$-Vubn$(l_IbwS02j;+6h2Q5F7P?Du2N!r;Ql$M>S7Frf*r3M`!bvWU zbTgl2p}E<*fv?`N8=B71Dk03J=K@EEQ^|GY*NoHaB~(}_ zx`Su{onY@5(Owc#f`!=H`+_#I<0#PTT9kxp4Ig;Y4*Zi>!ehJ3AiGpwSGd<{Q7Ddh z8jZ(NQ*Nsz5Mu_F_~rtIK$YnxRsOcP-XzNZ)r|)zZYfkLFE8jK)LV-oH{?#)EM%gW zV^O7T z0Kmc1`!7m_~ zJl!{Cb80G#fuJa1K3>!bT@5&ww_VSVYIh_R#~;If$43z`T4-@R=a1Px7r@*tdBOTw zj-VzI{klG5NP!tNEo#~KLk(n`6CMgiinc1-i79z$SlM+eaorY!WDll+m6%i+5_6Mc zf#5j#MYBbY)Z#rd21gtgo3y@c(zQVYaIYKI%y2oVzbPWm;IE#Cw$8O$fV}v}S%QDA zkwxW{fa#Goh1O|+=CF3h3DWNw+L^ly?BNQ7DY~Eca}5nt^>p#3cc9s3iDub0nh`Wy z?oH|dW8-HG@d5E@U>NWPjnhTjr7C${Iwj#;F2G@++N=Y2tjV;z57RNgE|kXQC)1h- zx8ODU>kk};J8KiSUx5jSsA_XPou1OH8=R~q9{`r>VnHkU6A=!zNOH8IGJoO!+bQys zDS2-H(7+Jfe+&zf#;OSV=83I|^M;0`Kv*#4%%O7x>@BgGMU*@ajUvY>cYw^`*jm@+ z{LZ2lr{OTMoQXn2XUsK-l72oysi9vgV4Sux^1GsW6zTV;?p#J06EvSVyUq5$f4kq< z{Chq5Z?I%ZW}6&uL+f&0uCW#^LyL!Ac2*QRII5TDGfZ43YpXyS^9%6HBqqog$Sal3 zJjI$J+@}ja9Xp)Bnbk+pi=*ZAHN}8q@g$$g<6_4?ej&Rw)I%w(%jgGlS5dTHN`9(^<}Hg zD$PbZX+X>;$v4NjGJxMDvVBiIam$cP-;h0YqQ{YgxYn-g&!}lHgaG3^B=>Z!D*7tp zu19e;r`u*+@4h41Da&NZv$qy-i6#DdI)EVvmKO*PvIKz-9E5R*k#|`$zJza8QJ)Q{ zf~Vl+I=8oaq)K!lL7Et5ycH;m&LKIvC|z4FH5bo|>#Kg5z+Jy*8Ifai}5A#%@)TgPRaC4f>Qk&} z4WciN&V(T~u^xBgH=iP(#nd;_@L&`7FUF>Qm-;hOljv(!74f&if;fz2Mg=b%^8$^C zna!2I&iCz&9I5ckX-5mVoAwz~)_&b#&k$e+pp=U2q-OjkS@yZ8ly1$2Vh?}yF0={P zPd3O@g{0L=eT-Dm9?imeUP(!As&DJ_D=5lwQ=3)XWXg)12CoB=-g-HX9RSXgL;yo0 z?$7z8Sy9w?DvA^u`Fnl7r_J&_jJ7claq*2l9E~#iJIWAPXuAHfmF3-4YjFYhOXkNJ zVz8BS_4KCUe68n{cPOTTuD<#H&?*|ayPR2-eJ2U0j$#P!>fhd(LXM>b_0^Gm27$;s ze#JTrkdpb*ws{iJ1jprw#ta&Lz6OjSJhJgmwIaVo!K}znCdX>y!=@@V_=VLZlF&@t z!{_emFt$Xar#gSZi_S5Sn#7tBp`eSwPf73&Dsh52J3bXLqWA`QLoVjU35Q3S4%|Zl zR2x4wGu^K--%q2y=+yDfT*Ktnh#24Sm86n`1p@vJRT|!$B3zs6OWxGN9<}T-XX>1; zxAt4#T(-D3XwskNhJZ6Gvd?3raBu$`W+c(+$2E{_E_;yghgs~U1&XO6$%47BLJF4O zXKZLVTr6kc$Ee0WUBU0cw+uAe!djN=dvD*scic%t)0Jp*1& zhjKqEK+U~w93c<~m_Oh;HX{|zgz=>@(45=Ynh{k#3xlfg!k z>hsq90wPe(!NljYbnuL6s`Z!wQSL8|(A*@M8K>`nPJ<9Hb^ zB6o?#^9zP>3hp0>JAite*3N?Rm>nJ1Lpq4)eqSe8KM_f(0DB?k8DNN6(3 zU#>-{0}3~vYJ7iIwC?Zbh@aJ8kfIvY%RveZltThMN73#Ew}jOwVw+|vU5u-wMoo9C zO(tv#&5`DOhlzunPV?M~qlM|K74x4cBC_AC?2GNw_-Uv&QtPOj(7L4NtVh$`J%xci zioGVvj5s|GY886)(}g`4WS3_%%PrF(O|s-n&-SdfbssL`!Gi7Hrz_r$IO@*$1fYbQ zgdp6?(IUaNPaH7}0%U|9X8HFonsJRrVwfmf*o1;k0+PwV^i%f7U{LAayu`!x*FmhN za(#a^@Idw9)jN)K!=sFC(G)ZNaYY169*IJ_ouY9>W8tC>S&MEp$+7 zy)NFumpuE>=7T@`j}8pa)MGpJaZoG(Ex3AzzH>gUU^eyWp*N2Fx+9*4k~BU;lQ1PG zj4)_JlelzJ==t*7=n2(}B4^^bqqcKFcJ7yVzbH_CWK?{eXdpKm);4|o{aM=M&`E$=_~PVi2>>L zKTN_x&qA)@ak=v=0Hl5H6~?LOfO@1+fu5(sB|VWID)w?%{m+n#7bLaszEJ#;$HMdt z9qP0gk)hIYvE1!jseA^FGTyK=i4eTPjTL$R;6FywMBZBPlh2ar9!8wlj1sinLF-1g zR5}hLq>pb1|AC-WcF!38e*kFv|9n<$etuB=xE%B=PUs}iVFl>m;BiWUqRIxYh7}L&2w@{SS-t(zUp`wLWAyO=PEE=Ekvn@YS*K@($=i zBkTMaH<&cAk${idNy0KZ8xh}u;eAl*tstdM8DYnM5N;bDa`AB+(8>DqX+mj17R2xBp45UES|H*#GHb_%Nc{xWs7l{0pqmiBIPe@r=X%Y-h<-Ceo;4I>isrw1Hd zZd*VjT`H9gxbf{b3krEKNAaV$k>SzK(gzv}>;byq##WEhzTN^@B4+VJvW>y|U}}AQ z4^Bdz9%QKBWCy+h$I?L@ffl{fLLL41Tx|M+NjjRf(`KjHG4^y=x3l z!!-{*v7_^6MiJOC@C$WV=hz9J^Y^lK9#tzs6}-

Gn4F+B~IivciU9^t0j-Mgao3 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 dDuazZG^u*-) zCV{{p$D*4m=Z;!QJ0C3EBfaUv#@3CmRgS-#a4+Uk9LtR(mzdSt-?*B-Q{Jgs!F;+_ zT&Jc~{N951OskjVtnWOLnfUacmSEkQ#fQojqa=HN?aN#ubFA){K&TrfoSnBphJ<43S zS5?OUrqPYpEqf;(zc;IIty0d`-n-Ke%PlArJt^DN>ZYW)ZS5z$Rnx=cxbM1#zPRNS1N0u>c z*gpBeTvyKAjdr#Uz%$J3C+p2~l9`-P6`=_1Y83#nAp`Kd5jfb*H#u;=%w#=&g~|Q% z1!N9S7n+#|tj#t98*^$X3S9)i3I*n?N+YghXJ9Z!QN%wvV7|W$;;Lx|1{)Ol%c4L- zlz|txtAK4ozLL`xMPZR7Sm6grRj@+Db&BXF9#sG|gMywm=IO1O01imHv3 zFjX9ri>su;HX}wK85k5$R0A&?fdtA5_Q_vX$jc(elo%M~QB>A-K~#2igOjH$d^80Z zC8#NjzZarXrcVZJJEF73z+ix4=7c`5N>TJt7>~rfl$^E}UAMlP2$P)vS26{cE?ONf_&Vd@-E2?PMU+S~pB diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4413138..d4081da4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index b740cf13..23d15a93 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -112,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -203,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. @@ -211,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 25da30db..db3a6ac2 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -68,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 4ef83ad4..95e30793 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,13 +1,10 @@ - tasks.test { useJUnitPlatform() } dependencies { - implementation("com.google.code.gson:gson:2.11.0") - testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.2") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.2") - testImplementation("org.junit.platform:junit-platform-suite:1.9.1") - testImplementation("org.mockito:mockito-core:5.12.0") - testImplementation("org.mockito:mockito-junit-jupiter:5.12.0") + implementation(libs.gson) + testImplementation(libs.bundles.junit.base) + testImplementation(libs.junit.platform) + testImplementation(libs.bundles.mockito) } From cb7a3c996573eb0b03810c38a9348d5cebda5dda Mon Sep 17 00:00:00 2001 From: zargess Date: Sun, 20 Jul 2025 19:33:01 +0200 Subject: [PATCH 3/7] Remove unused code Compression is not used by anything as has now been removed. --- .../com/fagi/compression/Compression.java | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 shared/src/main/java/com/fagi/compression/Compression.java diff --git a/shared/src/main/java/com/fagi/compression/Compression.java b/shared/src/main/java/com/fagi/compression/Compression.java deleted file mode 100644 index 1a6fad94..00000000 --- a/shared/src/main/java/com/fagi/compression/Compression.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.fagi.compression; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.zip.DataFormatException; -import java.util.zip.Deflater; -import java.util.zip.Inflater; - -/** - * Created by Marcus on 05-06-2016. - */ -public class Compression { - public static byte[] compress(byte[] data) throws IOException { - Deflater deflater = new Deflater(); - deflater.setInput(data); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(data.length); - deflater.finish(); - byte[] buffer = new byte[1024]; - while (!deflater.finished()) { - int count = deflater.deflate(buffer); // returns the generated code... index - outputStream.write(buffer, 0, count); - } - outputStream.close(); - byte[] output = outputStream.toByteArray(); - System.out.println("Original: " + data.length); - System.out.println("Compressed: " + output.length); - return output; - } - - public static byte[] decompress(byte[] data) throws IOException, DataFormatException { - Inflater inflater = new Inflater(); - inflater.setInput(data); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(data.length); - byte[] buffer = new byte[1024]; - while (!inflater.finished()) { - int count = inflater.inflate(buffer); - outputStream.write(buffer, 0, count); - } - outputStream.close(); - byte[] output = outputStream.toByteArray(); - System.out.println("Original: " + output.length); - System.out.println("Compressed: " + data.length); - return output; - } -} From 9beb2e4b406f8d98efb91b7210b466a41fa0daac Mon Sep 17 00:00:00 2001 From: zargess Date: Sat, 19 Jul 2025 10:19:25 +0200 Subject: [PATCH 4/7] Fagi Logging Framework ## Intro We have now introduced a framework that allows the Fagi code to not care what logging framework is being used. This allows us to very easily support multiple frameworks, or completely remove the usage of a framework, without having to edit too much of the code. ## FagiLoggerFactory This class allows for all other classes in Fagi to create a logger using the registered creation strategy. This allows us to quickly change framework a very central place. This class also handles the configuration of the logging framework used, by calling a configuration strategy. Though it only allows calling the default configuration if no custom configuration has been provided to the framework in use. ## JavaLogger The current logging framework to be used is the one the java standard library provides. ## Logging Config To ensure that the logging format of the client and server are acceptable, even if the user doesn't provide a logging config properties file, we have introduced a default config. This should only be used when there is no file specified in the property: java.util.logging.config.file. The default config makes use of a console logger and a file logger. --- settings.gradle.kts | 6 +- shared/build.gradle.kts | 8 + .../java/com/fagi/logging/FagiLogger.java | 83 ++++++ .../logging/FagiLoggerConfigStrategy.java | 31 ++ .../logging/FagiLoggerCreationStrategy.java | 22 ++ .../com/fagi/logging/FagiLoggerFactory.java | 88 ++++++ .../com/fagi/logging/java/JavaLogger.java | 98 +++++++ .../java/JavaLoggerConfigStrategy.java | 56 ++++ .../java/JavaLoggerCreationStrategy.java | 16 + .../logging/java/JavaLoggerFormatter.java | 60 ++++ .../fagi/logging/FagiLoggerFactoryTest.java | 99 +++++++ .../java/JavaLoggerConfigStrategyTest.java | 275 ++++++++++++++++++ .../java/JavaLoggerCreationStrategyTest.java | 22 ++ .../logging/java/JavaLoggerFormatterTest.java | 37 +++ .../logging/java/TestJavaLoggingHandler.java | 36 +++ .../java/com/fagi/BaseFagiTest.java | 96 ++++++ .../java/com/fagi/logging/TestLogLevel.java | 11 + .../java/com/fagi/logging/TestLogRecord.java | 12 + .../java/com/fagi/logging/TestLogger.java | 130 +++++++++ .../TestLoggerConfigurationStrategy.java | 35 +++ .../logging/TestLoggerCreationStrategy.java | 22 ++ 21 files changed, 1240 insertions(+), 3 deletions(-) create mode 100644 shared/src/main/java/com/fagi/logging/FagiLogger.java create mode 100644 shared/src/main/java/com/fagi/logging/FagiLoggerConfigStrategy.java create mode 100644 shared/src/main/java/com/fagi/logging/FagiLoggerCreationStrategy.java create mode 100644 shared/src/main/java/com/fagi/logging/FagiLoggerFactory.java create mode 100644 shared/src/main/java/com/fagi/logging/java/JavaLogger.java create mode 100644 shared/src/main/java/com/fagi/logging/java/JavaLoggerConfigStrategy.java create mode 100644 shared/src/main/java/com/fagi/logging/java/JavaLoggerCreationStrategy.java create mode 100644 shared/src/main/java/com/fagi/logging/java/JavaLoggerFormatter.java create mode 100644 shared/src/test/java/com/fagi/logging/FagiLoggerFactoryTest.java create mode 100644 shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java create mode 100644 shared/src/test/java/com/fagi/logging/java/JavaLoggerCreationStrategyTest.java create mode 100644 shared/src/test/java/com/fagi/logging/java/JavaLoggerFormatterTest.java create mode 100644 shared/src/test/java/com/fagi/logging/java/TestJavaLoggingHandler.java create mode 100644 shared/src/testFixtures/java/com/fagi/BaseFagiTest.java create mode 100644 shared/src/testFixtures/java/com/fagi/logging/TestLogLevel.java create mode 100644 shared/src/testFixtures/java/com/fagi/logging/TestLogRecord.java create mode 100644 shared/src/testFixtures/java/com/fagi/logging/TestLogger.java create mode 100644 shared/src/testFixtures/java/com/fagi/logging/TestLoggerConfigurationStrategy.java create mode 100644 shared/src/testFixtures/java/com/fagi/logging/TestLoggerCreationStrategy.java diff --git a/settings.gradle.kts b/settings.gradle.kts index a5cbd95a..d0449477 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,5 @@ rootProject.name = "fagi" -include("fagiClient") -include("shared") -include("fagiServer") \ No newline at end of file +include(":fagiClient") +include(":fagiServer") +include(":shared") \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 95e30793..baf9f80f 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,9 +1,17 @@ +plugins { + id("java-test-fixtures") +} + tasks.test { useJUnitPlatform() } dependencies { implementation(libs.gson) + + testFixturesImplementation(libs.junit.jupiter.api) + + testImplementation(testFixtures(project(":shared"))) testImplementation(libs.bundles.junit.base) testImplementation(libs.junit.platform) testImplementation(libs.bundles.mockito) diff --git a/shared/src/main/java/com/fagi/logging/FagiLogger.java b/shared/src/main/java/com/fagi/logging/FagiLogger.java new file mode 100644 index 00000000..fd6d1aad --- /dev/null +++ b/shared/src/main/java/com/fagi/logging/FagiLogger.java @@ -0,0 +1,83 @@ +package com.fagi.logging; + +import java.util.function.Supplier; + +/** + *

+ * A Logger interface used to log messages for the Fagi server and client. + *

+ *

+ * This interface helps hide what logging framework is used, and thus makes it simpler to change framework without having to change a lot of code. + *

+ * + * @author Marcus Haagh + */ +public interface FagiLogger { + /** + * Logs message in DEBUG level + * + * @param messageSupplier a supplier that returns the message to be logged + */ + void debug(Supplier messageSupplier); + + /** + * Logs message in DEBUG level along with the associated throwable + * + * @param throwable the throwable to be logged + * @param messageSupplier a supplier that returns the message to be logged + */ + void debug( + Throwable throwable, + Supplier messageSupplier); + + /** + * Logs message in INFO level + * + * @param messageSupplier a supplier that returns the message to be logged + */ + void info(Supplier messageSupplier); + + /** + * Logs message in INFO level along with the associated throwable + * + * @param throwable the throwable to be logged + * @param messageSupplier a supplier that returns the message to be logged + */ + void info( + Throwable throwable, + Supplier messageSupplier); + + /** + * Logs message in WARNING level + * + * @param messageSupplier a supplier that returns the message to be logged + */ + void warning(Supplier messageSupplier); + + /** + * Logs message in WARNING level along with the associated throwable + * + * @param throwable the throwable to be logged + * @param messageSupplier a supplier that returns the message to be logged + */ + void warning( + Throwable throwable, + Supplier messageSupplier); + + /** + * Logs message in ERROR level + * + * @param messageSupplier a supplier that returns the message to be logged + */ + void error(Supplier messageSupplier); + + /** + * Logs message in ERROR level along with the associated throwable + * + * @param throwable the throwable to be logged + * @param messageSupplier a supplier that returns the message to be logged + */ + void error( + Throwable throwable, + Supplier messageSupplier); +} diff --git a/shared/src/main/java/com/fagi/logging/FagiLoggerConfigStrategy.java b/shared/src/main/java/com/fagi/logging/FagiLoggerConfigStrategy.java new file mode 100644 index 00000000..6edd46b9 --- /dev/null +++ b/shared/src/main/java/com/fagi/logging/FagiLoggerConfigStrategy.java @@ -0,0 +1,31 @@ +package com.fagi.logging; + +import java.nio.file.Path; + +/** + *

+ * This strategy can check if a default configuration should be used and create the default logging configuration. + *

+ *

+ * The strategy must be set in the {@link FagiLoggerFactory} in order to be used. + *

+ * + * @author Marcus Haagh + * @see FagiLoggerFactory + */ +public interface FagiLoggerConfigStrategy { + /** + * Checks if custom configuration has been provided. + * + * @return true if logging configuration has been provided by the user + */ + boolean isCustomConfigurationAvailable(); + + /** + * Sets up the logging in a default manner. This ensures that proper logging is configured, even if the user hasn't + * provided a log config file. + * + * @param logFile the path of the resulting log file. + */ + void setupDefaultConfiguration(Path logFile); +} diff --git a/shared/src/main/java/com/fagi/logging/FagiLoggerCreationStrategy.java b/shared/src/main/java/com/fagi/logging/FagiLoggerCreationStrategy.java new file mode 100644 index 00000000..cd40ae31 --- /dev/null +++ b/shared/src/main/java/com/fagi/logging/FagiLoggerCreationStrategy.java @@ -0,0 +1,22 @@ +package com.fagi.logging; + +/** + *

+ * A strategy to help create a {@link FagiLogger}. + *

+ *

+ * The strategy should hold the details on how to instantiate a new instance of a {@link FagiLogger}. The strategy must be set in the {@link FagiLoggerFactory} in order to be used. + *

+ * + * @author Marcus Haagh + * @see FagiLogger + */ +public interface FagiLoggerCreationStrategy { + /** + * Creates a logger for the given class. + * + * @param tClass the class the new logger should be associated with. + * @return a {@link FagiLogger} + */ + FagiLogger createLogger(Class tClass); +} diff --git a/shared/src/main/java/com/fagi/logging/FagiLoggerFactory.java b/shared/src/main/java/com/fagi/logging/FagiLoggerFactory.java new file mode 100644 index 00000000..ad7ab982 --- /dev/null +++ b/shared/src/main/java/com/fagi/logging/FagiLoggerFactory.java @@ -0,0 +1,88 @@ +package com.fagi.logging; + +import com.fagi.logging.java.JavaLoggerConfigStrategy; +import com.fagi.logging.java.JavaLoggerCreationStrategy; + +import java.nio.file.Path; + +/** + * This class is used to aid in creating and configuring {@link FagiLogger}s. This is to make it simpler to change + * logging frameworks, both during testing and in cases where we want to change the entire framework. + * + * @author Marcus Haagh + * @see FagiLogger + * @see FagiLoggerCreationStrategy + * @see FagiLoggerConfigStrategy + */ +public class FagiLoggerFactory { + /** + * This contains the creation strategy to be used. Should this change, then make sure that the config strategy is + * changed as well. + */ + private static FagiLoggerCreationStrategy loggerCreationStrategy = new JavaLoggerCreationStrategy(); + /** + * This contains the config strategy to be used.Should this change, then make sure that the creation strategy is + * changed as well. + */ + private static FagiLoggerConfigStrategy loggerConfigStrategy = new JavaLoggerConfigStrategy(); + private static FagiLogger LOGGER = createLogger(FagiLoggerFactory.class); + + private FagiLoggerFactory() { + throw new UnsupportedOperationException("Factory class - do not instantiate"); + } + + /** + * Creates a FagiLogger for the given class + * + * @param tClass the class for the logger to be created for + * @return a FagiLogger + */ + public static FagiLogger createLogger(Class tClass) { + return loggerCreationStrategy.createLogger(tClass); + } + + /** + * Determines if custom configuration has been provided by the user. If that's the case, + * then configuration should not be overridden with the default configuration. + * + * @return true if logging configuration has been provided by the user + */ + public static boolean isCustomConfigurationAvailable() { + return loggerConfigStrategy.isCustomConfigurationAvailable(); + } + + /** + *

+ * Sets up the default logging configuration. + *

+ *

+ * Does nothing if {@link FagiLoggerFactory#isCustomConfigurationAvailable()} is true + *

+ * + * @param logFile the file where logs will be stored + */ + public static void setupDefaultConfiguration(Path logFile) { + if (!isCustomConfigurationAvailable()) { + loggerConfigStrategy.setupDefaultConfiguration(logFile); + } else { + LOGGER.debug(() -> "Custom configuration has been provided. No further configuration is made."); + } + } + + public static void setLoggerCreationStrategy(FagiLoggerCreationStrategy strategy) { + loggerCreationStrategy = strategy; + } + + public static void setLoggerConfigStrategy(FagiLoggerConfigStrategy loggerConfigStrategy) { + FagiLoggerFactory.loggerConfigStrategy = loggerConfigStrategy; + } + + /** + * Used for testing as the logger on the class is created before any test class can override the strategies. + * + * @param logger the logger to be used by the factory + */ + static void setLogger(FagiLogger logger) { + LOGGER = logger; + } +} diff --git a/shared/src/main/java/com/fagi/logging/java/JavaLogger.java b/shared/src/main/java/com/fagi/logging/java/JavaLogger.java new file mode 100644 index 00000000..939975bd --- /dev/null +++ b/shared/src/main/java/com/fagi/logging/java/JavaLogger.java @@ -0,0 +1,98 @@ +package com.fagi.logging.java; + +import com.fagi.logging.FagiLogger; + +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * A {@link FagiLogger} implementation that uses {@link Logger} from the Java Standard Library. + * + * @author Marcus Haagh + * @see FagiLogger + * @see Logger + */ +public class JavaLogger implements FagiLogger { + private final Logger logger; + + public JavaLogger(Class tClass) { + this.logger = Logger.getLogger(tClass.getName()); + } + + @Override + public void debug(Supplier messageSupplier) { + logger.log( + Level.FINE, + messageSupplier + ); + } + + @Override + public void debug( + Throwable throwable, + Supplier messageSupplier) { + logger.log( + Level.FINE, + throwable, + messageSupplier + ); + } + + @Override + public void info(Supplier messageSupplier) { + logger.log( + Level.INFO, + messageSupplier + ); + } + + @Override + public void info( + Throwable throwable, + Supplier messageSupplier) { + logger.log( + Level.INFO, + throwable, + messageSupplier + ); + } + + @Override + public void warning(Supplier messageSupplier) { + logger.log( + Level.WARNING, + messageSupplier + ); + } + + @Override + public void warning( + Throwable throwable, + Supplier messageSupplier) { + logger.log( + Level.WARNING, + throwable, + messageSupplier + ); + } + + @Override + public void error(Supplier messageSupplier) { + logger.log( + Level.SEVERE, + messageSupplier + ); + } + + @Override + public void error( + Throwable throwable, + Supplier messageSupplier) { + logger.log( + Level.SEVERE, + throwable, + messageSupplier + ); + } +} diff --git a/shared/src/main/java/com/fagi/logging/java/JavaLoggerConfigStrategy.java b/shared/src/main/java/com/fagi/logging/java/JavaLoggerConfigStrategy.java new file mode 100644 index 00000000..b47cc8f0 --- /dev/null +++ b/shared/src/main/java/com/fagi/logging/java/JavaLoggerConfigStrategy.java @@ -0,0 +1,56 @@ +package com.fagi.logging.java; + +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerConfigStrategy; +import com.fagi.logging.FagiLoggerFactory; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.logging.ConsoleHandler; +import java.util.logging.FileHandler; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * This strategy configures {@link Logger} from the Java Standard Library. + * + * @author Marcus Haagh + */ +public class JavaLoggerConfigStrategy implements FagiLoggerConfigStrategy { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(JavaLoggerConfigStrategy.class); + static final String LOGGING_CONFIG_FILE_SYSTEM_PROPERTY_NAME = "java.util.logging.config.file"; + + @Override + public boolean isCustomConfigurationAvailable() { + return System.getProperty(LOGGING_CONFIG_FILE_SYSTEM_PROPERTY_NAME) != null; + } + + @Override + public void setupDefaultConfiguration(Path logFile) { + var logLevel = Level.INFO; + var rootLogger = Logger.getLogger(""); + rootLogger.setLevel(logLevel); + + for (var handler : rootLogger.getHandlers()) { + rootLogger.removeHandler(handler); + } + + var consoleHandler = new ConsoleHandler(); + consoleHandler.setLevel(logLevel); + consoleHandler.setFormatter(new JavaLoggerFormatter()); + + rootLogger.addHandler(consoleHandler); + + try { + var fileHandler = new FileHandler(logFile.toString()); + fileHandler.setLevel(logLevel); + fileHandler.setFormatter(new JavaLoggerFormatter()); + rootLogger.addHandler(fileHandler); + } catch (IOException e) { + LOGGER.error( + e, + () -> "Failed to setup file handler for logger. Log statements will not be saved in files." + ); + } + } +} diff --git a/shared/src/main/java/com/fagi/logging/java/JavaLoggerCreationStrategy.java b/shared/src/main/java/com/fagi/logging/java/JavaLoggerCreationStrategy.java new file mode 100644 index 00000000..07952da6 --- /dev/null +++ b/shared/src/main/java/com/fagi/logging/java/JavaLoggerCreationStrategy.java @@ -0,0 +1,16 @@ +package com.fagi.logging.java; + +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerCreationStrategy; + +/** + * Creates a {@link java.util.logging.Logger} from the Java Standard Library + * + * @author Marcus Haagh + */ +public class JavaLoggerCreationStrategy implements FagiLoggerCreationStrategy { + @Override + public FagiLogger createLogger(Class tClass) { + return new JavaLogger(tClass); + } +} diff --git a/shared/src/main/java/com/fagi/logging/java/JavaLoggerFormatter.java b/shared/src/main/java/com/fagi/logging/java/JavaLoggerFormatter.java new file mode 100644 index 00000000..df988ace --- /dev/null +++ b/shared/src/main/java/com/fagi/logging/java/JavaLoggerFormatter.java @@ -0,0 +1,60 @@ +package com.fagi.logging.java; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.logging.Formatter; +import java.util.logging.LogRecord; + +/** + *

+ * This class formats logs in the default Fagi format. The format looks as follows: + *

+ *

+ * yyyy-MM-dd HH:mm:ss [LOG LEVEL] full.classpath.for.class - LOG MESSAGE + *

+ *

+ * The following is an example of a log statement: + *

+ *

+ * 2025-07-19 09:42:37 [INFO] com.fagi.server.Server - Starting Server + *

+ *

+ * If there is an exception in the log, the stacktrace will start on the next line. + *

+ *

+ * This should only be used if the user hasn't specified their own logging configuration file. + *

+ * + * @author Marcus Haagh + */ +public class JavaLoggerFormatter extends Formatter { + final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + @Override + public String format(LogRecord record) { + String loggerName = record.getLoggerName(); + + StringBuilder sb = new StringBuilder(); + + sb.append(dateFormat.format(new Date(record.getMillis()))); + + sb.append(String.format( + " [%s] %s - %s%n", + record.getLevel(), + loggerName, + formatMessage(record) + )); + + if (record.getThrown() != null) { + StringWriter sw = new StringWriter(); + record + .getThrown() + .printStackTrace(new PrintWriter(sw)); + sb.append(sw); + } + + return sb.toString(); + } +} diff --git a/shared/src/test/java/com/fagi/logging/FagiLoggerFactoryTest.java b/shared/src/test/java/com/fagi/logging/FagiLoggerFactoryTest.java new file mode 100644 index 00000000..f7d0d6e4 --- /dev/null +++ b/shared/src/test/java/com/fagi/logging/FagiLoggerFactoryTest.java @@ -0,0 +1,99 @@ +package com.fagi.logging; + +import com.fagi.BaseFagiTest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; + +public class FagiLoggerFactoryTest extends BaseFagiTest { + @Test + void testCanCreateLogger() { + FagiLogger logger = FagiLoggerFactory.createLogger(FagiLoggerFactoryTest.class); + + Assertions.assertNotNull(logger); + Assertions.assertInstanceOf( + TestLogger.class, + logger + ); + Assertions.assertEquals( + FagiLoggerFactoryTest.class, + ((TestLogger) logger).getLoggerClass() + ); + } + + @Test + void testConfigurationAvailableWhenStrategyReturnsTrue() { + FagiLoggerFactory.setLoggerConfigStrategy(new TestLoggerConfigurationStrategy(true)); + + Assertions.assertTrue(FagiLoggerFactory.isCustomConfigurationAvailable()); + } + + @Test + void testConfigurationNotAvailableWhenStrategyReturnsFalse() { + FagiLoggerFactory.setLoggerConfigStrategy(new TestLoggerConfigurationStrategy(false)); + + Assertions.assertFalse(FagiLoggerFactory.isCustomConfigurationAvailable()); + } + + @Test + void testFactoryUsesConfigurationStrategyWhenNoCustomConfigurationIsProvided() { + // Creates logger to be used by the factory. This allows us to check the log records later. + FagiLoggerFactory.setLogger(FagiLoggerFactory.createLogger(FagiLoggerFactory.class)); + + TestLoggerConfigurationStrategy loggerConfigStrategy = new TestLoggerConfigurationStrategy(false); + FagiLoggerFactory.setLoggerConfigStrategy(loggerConfigStrategy); + + Path logFile = Path.of("/some/path"); + FagiLoggerFactory.setupDefaultConfiguration(logFile); + + List> testLogRecords = lookupLogRecordsForClass(FagiLoggerFactory.class); + + Assertions.assertAll( + () -> Assertions.assertTrue(testLogRecords.isEmpty()), + () -> Assertions.assertTrue(loggerConfigStrategy.isConfigured()), + () -> Assertions.assertEquals( + logFile, + loggerConfigStrategy.getLogFile() + ) + ); + } + + @Test + void testFactoryDebugLogsWhenItShouldntUseDefaultConfiguration() { + // Creates logger to be used by the factory. This allows us to check the log records later. + FagiLoggerFactory.setLogger(FagiLoggerFactory.createLogger(FagiLoggerFactory.class)); + + TestLoggerConfigurationStrategy loggerConfigStrategy = new TestLoggerConfigurationStrategy(true); + FagiLoggerFactory.setLoggerConfigStrategy(loggerConfigStrategy); + + FagiLoggerFactory.setupDefaultConfiguration(Path.of("/some/path")); + + List> testLogRecords = lookupLogRecordsForClass(FagiLoggerFactory.class); + + Assertions.assertEquals( + 1, + testLogRecords.size() + ); + + TestLogRecord logRecord = testLogRecords.getFirst(); + + Assertions.assertAll( + () -> Assertions.assertEquals( + TestLogLevel.DEBUG, + logRecord.logLevel() + ), + () -> Assertions.assertEquals( + FagiLoggerFactory.class, + logRecord.loggerClass() + ), + () -> Assertions.assertEquals( + "Custom configuration has been provided. No further configuration is made.", + logRecord.message() + ), + () -> Assertions.assertNull(logRecord.throwable()), + () -> Assertions.assertFalse(loggerConfigStrategy.isConfigured()) + ); + } +} diff --git a/shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java b/shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java new file mode 100644 index 00000000..cf4a654c --- /dev/null +++ b/shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java @@ -0,0 +1,275 @@ +package com.fagi.logging.java; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.logging.ConsoleHandler; +import java.util.logging.FileHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +/** + * Doesn't utilise {@link com.fagi.BaseFagiTest} as we want to test the real logging framework to check if the + * logs work as expected. + */ +class JavaLoggerConfigStrategyTest { + private static Path tempDir; + private final Logger LOGGER = Logger.getLogger(JavaLoggerConfigStrategy.class.getName()); + private final TestJavaLoggingHandler testJavaLoggingHandler = new TestJavaLoggingHandler(); + private final JavaLoggerConfigStrategy strategy = new JavaLoggerConfigStrategy(); + + @BeforeAll + static void setupClass() throws IOException { + tempDir = Path.of("build/test_logs"); + // Deleting the "build/test_logs" folder before executing the test. + // This is done to mitigate Windows locking the log files in such a way that they cannot be + // deleted after running the tests. + tempDir + .toFile() + .delete(); + + // Create the "build/test_logs" folder + Files.createDirectory(tempDir); + } + + @BeforeEach + void setup() { + LOGGER.addHandler(testJavaLoggingHandler); + } + + @Test + void testRootLoggerHasConsoleAndFileHandlersAndBothHasCorrectFormatter() { + strategy.setupDefaultConfiguration(createLogFile()); + + var logger = Logger.getLogger(""); + + Assertions.assertAll( + () -> Assertions.assertEquals( + 2, + logger.getHandlers().length + ), + () -> Assertions.assertTrue(Arrays + .stream(logger.getHandlers()) + .anyMatch(h -> h instanceof ConsoleHandler)), + () -> Assertions.assertTrue(Arrays + .stream(logger.getHandlers()) + .anyMatch(h -> h instanceof FileHandler)), + () -> Assertions.assertTrue(Arrays + .stream(logger.getHandlers()) + .allMatch(h -> h.getFormatter() instanceof JavaLoggerFormatter)) + ); + + cleanHandlers(); + } + + @Test + void testConfigLogsIfFailsToMakeFileHandler() throws IOException { + Path restrictedFile = createLogFile(); + Files.createFile(restrictedFile); + + // Remove read/write permissions on file to force IOException. + restrictedFile + .toFile() + .setReadable( + false, + false + ); + restrictedFile + .toFile() + .setWritable( + false, + false + ); + + strategy.setupDefaultConfiguration(restrictedFile.toAbsolutePath()); + + List logRecords = testJavaLoggingHandler.getLogRecords(); + Assertions.assertAll( + () -> Assertions.assertEquals( + 1, + logRecords.size() + ), + () -> Assertions.assertEquals( + Level.SEVERE, + logRecords + .getFirst() + .getLevel() + ), + () -> Assertions.assertEquals( + "Failed to setup file handler for logger. Log statements will not be saved in files.", + logRecords + .getFirst() + .getMessage() + ), + () -> Assertions.assertInstanceOf( + IOException.class, + logRecords + .getFirst() + .getThrown() + ) + ); + + cleanHandlers(); + } + + @Test + void testShouldNotLogFineRecords() { + strategy.setupDefaultConfiguration(createLogFile()); + + LOGGER.fine(() -> "This record should not be recorded."); + + Assertions.assertEquals( + 0, + testJavaLoggingHandler + .getLogRecords() + .size() + ); + + cleanHandlers(); + } + + @Test + void testShouldNotLogFinerRecords() { + strategy.setupDefaultConfiguration(createLogFile()); + + LOGGER.finer(() -> "This record should not be recorded."); + + Assertions.assertEquals( + 0, + testJavaLoggingHandler + .getLogRecords() + .size() + ); + + cleanHandlers(); + } + + @Test + void testShouldNotLogFinestRecords() { + strategy.setupDefaultConfiguration(createLogFile()); + + LOGGER.finest(() -> "This record should not be recorded."); + + Assertions.assertEquals( + 0, + testJavaLoggingHandler + .getLogRecords() + .size() + ); + + cleanHandlers(); + } + + @Test + void testShouldLogInfoRecords() { + strategy.setupDefaultConfiguration(createLogFile()); + + LOGGER.info(() -> "This record should be recorded."); + + Assertions.assertAll(() -> Assertions.assertEquals( + 1, + testJavaLoggingHandler + .getLogRecords() + .size() + )); + + cleanHandlers(); + } + + @Test + void testShouldLogWarningRecords() { + strategy.setupDefaultConfiguration(createLogFile()); + + LOGGER.warning(() -> "This record should be recorded."); + + Assertions.assertAll(() -> Assertions.assertEquals( + 1, + testJavaLoggingHandler + .getLogRecords() + .size() + )); + + cleanHandlers(); + } + + @Test + void testShouldLogSevereRecords() { + strategy.setupDefaultConfiguration(createLogFile()); + + LOGGER.severe(() -> "This record should be recorded."); + + Assertions.assertAll(() -> Assertions.assertEquals( + 1, + testJavaLoggingHandler + .getLogRecords() + .size() + )); + + cleanHandlers(); + } + + @Test + void testCustomConfigCheckIsTrueWhenPropertyIsNonNull() { + String sysPropertyOldValue = System.getProperty(JavaLoggerConfigStrategy.LOGGING_CONFIG_FILE_SYSTEM_PROPERTY_NAME); + System.setProperty( + JavaLoggerConfigStrategy.LOGGING_CONFIG_FILE_SYSTEM_PROPERTY_NAME, + "Some non-null value" + ); + + boolean isCustomConfigAvailable = strategy.isCustomConfigurationAvailable(); + + Assertions.assertTrue(isCustomConfigAvailable); + + if (sysPropertyOldValue != null) { + System.setProperty( + JavaLoggerConfigStrategy.LOGGING_CONFIG_FILE_SYSTEM_PROPERTY_NAME, + sysPropertyOldValue + ); + } else { + System.clearProperty(JavaLoggerConfigStrategy.LOGGING_CONFIG_FILE_SYSTEM_PROPERTY_NAME); + } + + cleanHandlers(); + } + + @Test + void testCustomConfigCheckIsFalseWhenPropertyIsNull() { + String sysPropertyOldValue = System.getProperty(JavaLoggerConfigStrategy.LOGGING_CONFIG_FILE_SYSTEM_PROPERTY_NAME); + System.clearProperty(JavaLoggerConfigStrategy.LOGGING_CONFIG_FILE_SYSTEM_PROPERTY_NAME); + + boolean isCustomConfigAvailable = strategy.isCustomConfigurationAvailable(); + + Assertions.assertFalse(isCustomConfigAvailable); + + if (sysPropertyOldValue != null) { + System.setProperty( + JavaLoggerConfigStrategy.LOGGING_CONFIG_FILE_SYSTEM_PROPERTY_NAME, + sysPropertyOldValue + ); + } + + cleanHandlers(); + } + + private static Path createLogFile() { + return tempDir.resolve("test_" + System.currentTimeMillis() + ".log"); + } + + private void cleanHandlers() { + Handler[] handlers = LOGGER.getHandlers(); + for (Handler handler : handlers) { + handler.flush(); + handler.close(); + LOGGER.removeHandler(handler); + } + } +} diff --git a/shared/src/test/java/com/fagi/logging/java/JavaLoggerCreationStrategyTest.java b/shared/src/test/java/com/fagi/logging/java/JavaLoggerCreationStrategyTest.java new file mode 100644 index 00000000..afb71b8f --- /dev/null +++ b/shared/src/test/java/com/fagi/logging/java/JavaLoggerCreationStrategyTest.java @@ -0,0 +1,22 @@ +package com.fagi.logging.java; + +import com.fagi.logging.FagiLogger; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class JavaLoggerCreationStrategyTest { + @Test + void verifyStrategyCreatesJavaLogger() { + var strategy = new JavaLoggerCreationStrategy(); + + FagiLogger logger = strategy.createLogger(JavaLoggerCreationStrategyTest.class); + + Assertions.assertAll( + () -> Assertions.assertNotNull(logger), + () -> Assertions.assertInstanceOf( + JavaLogger.class, + logger + ) + ); + } +} diff --git a/shared/src/test/java/com/fagi/logging/java/JavaLoggerFormatterTest.java b/shared/src/test/java/com/fagi/logging/java/JavaLoggerFormatterTest.java new file mode 100644 index 00000000..2196e652 --- /dev/null +++ b/shared/src/test/java/com/fagi/logging/java/JavaLoggerFormatterTest.java @@ -0,0 +1,37 @@ +package com.fagi.logging.java; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Date; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +class JavaLoggerFormatterTest { + private final JavaLoggerFormatter formatter = new JavaLoggerFormatter(); + + @Test + void testFormatterShouldGiveCorrectFormattedStrings() { + var logRecord = new LogRecord( + Level.WARNING, + "This is the log message" + ); + logRecord.setLoggerName(JavaLoggerFormatterTest.class.getName()); + logRecord.setThrown(new IOException()); + + String dateTimeString = formatter.dateFormat.format(new Date(logRecord.getMillis())); + + var expectedFormattedLogEntry = dateTimeString + " [" + logRecord.getLevel() + "] " + logRecord.getLoggerName() + " - " + logRecord.getMessage() + System.lineSeparator() + IOException.class.getName(); + + // Normalize line endings such that the test works on different operating systems + String formattedLogRecord = formatter + .format(logRecord) + .replace( + "\n\r", + System.lineSeparator() + ); + + Assertions.assertTrue(formattedLogRecord.startsWith(expectedFormattedLogEntry)); + } +} \ No newline at end of file diff --git a/shared/src/test/java/com/fagi/logging/java/TestJavaLoggingHandler.java b/shared/src/test/java/com/fagi/logging/java/TestJavaLoggingHandler.java new file mode 100644 index 00000000..da094395 --- /dev/null +++ b/shared/src/test/java/com/fagi/logging/java/TestJavaLoggingHandler.java @@ -0,0 +1,36 @@ +package com.fagi.logging.java; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.LogRecord; + +/** + * This class is used to record all logs sent to a logger + */ +public class TestJavaLoggingHandler extends Handler { + private final List logRecords = new ArrayList<>(); + + @Override + public void publish(LogRecord record) { + logRecords.add(record); + } + + /** + * Does nothing + */ + @Override + public void flush() { + } + + /** + * Does nothing + */ + @Override + public void close() throws SecurityException { + } + + public List getLogRecords() { + return logRecords; + } +} diff --git a/shared/src/testFixtures/java/com/fagi/BaseFagiTest.java b/shared/src/testFixtures/java/com/fagi/BaseFagiTest.java new file mode 100644 index 00000000..2a71407d --- /dev/null +++ b/shared/src/testFixtures/java/com/fagi/BaseFagiTest.java @@ -0,0 +1,96 @@ +package com.fagi; + +import com.fagi.logging.FagiLoggerFactory; +import com.fagi.logging.TestLogLevel; +import com.fagi.logging.TestLogRecord; +import com.fagi.logging.TestLogger; +import com.fagi.logging.TestLoggerConfigurationStrategy; +import com.fagi.logging.TestLoggerCreationStrategy; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + *

+ * This abstract class offers utility to make unit tests easier to write. + *

+ *

+ * All unit tests should inherit from this class. + *

+ */ +public abstract class BaseFagiTest { + /** + * All loggers registered during a test class will be stored here. This allows for reading log records + * and clearing log records between tests. + */ + private static final Map, TestLogger> LOGGERS = new HashMap<>(); + + @BeforeAll + static void baseTestClassSetup() { + FagiLoggerFactory.setLoggerCreationStrategy(new TestLoggerCreationStrategy(LOGGERS)); + FagiLoggerFactory.setLoggerConfigStrategy(new TestLoggerConfigurationStrategy(false)); + } + + /** + *

+ * Performs tear down after every test. + *

+ *

+ * This currently does the following: + *

    + *
  • Clears log records from all registered loggers.
  • + *
+ *

+ */ + @AfterEach + void baseTestTearDown() { + for (TestLogger logger : LOGGERS.values()) { + logger.clearRecords(); + } + } + + /** + *

+ * Allows tests to lookup log records from a logger registered on the given Class. + *

+ *

+ * To be used when testing if logs have been made correctly. + *

+ * + * @param tClass the class the logger was registered on. + * @return a list of records made with a logger registered on the given Class. + * @throws AssertionError if no logger has been registered on the given Class. + */ + public List> lookupLogRecordsForClass(Class tClass) { + var logger = LOGGERS.get(tClass); + if (logger != null) { + return logger.getLogRecords(); + } + throw new AssertionError("No logger has been registered for class: " + tClass); + } + + /** + *

+ * Allows tests to lookup log records from a logger registered on the given Class with the given log level. + *

+ *

+ * To be used when testing if logs have been made correctly. + *

+ * + * @param tClass the class the logger was registered on. + * @param logLevel the log level of records to be found. + * @return a list of records made with a logger registered on the given Class at the given level. + * @throws AssertionError if no logger has been registered on the given Class. + */ + public List> lookupLogRecordsForClass( + Class tClass, + TestLogLevel logLevel) { + return lookupLogRecordsForClass(tClass) + .stream() + .filter(logRecord -> logLevel.equals(logRecord.logLevel())) + .toList(); + } +} diff --git a/shared/src/testFixtures/java/com/fagi/logging/TestLogLevel.java b/shared/src/testFixtures/java/com/fagi/logging/TestLogLevel.java new file mode 100644 index 00000000..7e7720b9 --- /dev/null +++ b/shared/src/testFixtures/java/com/fagi/logging/TestLogLevel.java @@ -0,0 +1,11 @@ +package com.fagi.logging; + +/** + * A test representation of log levels. + */ +public enum TestLogLevel { + DEBUG, + INFO, + WARNING, + ERROR +} diff --git a/shared/src/testFixtures/java/com/fagi/logging/TestLogRecord.java b/shared/src/testFixtures/java/com/fagi/logging/TestLogRecord.java new file mode 100644 index 00000000..5eb7c4fe --- /dev/null +++ b/shared/src/testFixtures/java/com/fagi/logging/TestLogRecord.java @@ -0,0 +1,12 @@ +package com.fagi.logging; + +/** + * A test representation of a log record. Made to be agnostic about the logging framework used by the production code. + * + * @param loggerClass the Class the logger that made the record was registered on. + * @param logLevel the log level the log record was made to. + * @param message the logged message. + * @param throwable the throwable associated with the message. + */ +public record TestLogRecord(Class loggerClass, TestLogLevel logLevel, String message, Throwable throwable) { +} diff --git a/shared/src/testFixtures/java/com/fagi/logging/TestLogger.java b/shared/src/testFixtures/java/com/fagi/logging/TestLogger.java new file mode 100644 index 00000000..497c2bd3 --- /dev/null +++ b/shared/src/testFixtures/java/com/fagi/logging/TestLogger.java @@ -0,0 +1,130 @@ +package com.fagi.logging; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + *

+ * This class is used to simulate a logger during unit tests, making it easier to test that the correct + * logs were made. + *

+ *

+ * All log records are stored in a list, ready to be read by tests. + *

+ *

+ * Log records should be cleared after every unit test as loggers are stored statically, + * making it possible for records to carry over to other tests. + *

+ */ +public class TestLogger implements FagiLogger { + private final List> logRecords = new ArrayList<>(); + private final Class loggerClass; + + public TestLogger(Class tClass) { + this.loggerClass = tClass; + } + + @Override + public void debug(Supplier messageSupplier) { + logRecords.add(new TestLogRecord<>( + loggerClass, + TestLogLevel.DEBUG, + messageSupplier.get(), + null + )); + } + + @Override + public void debug( + Throwable throwable, + Supplier messageSupplier) { + logRecords.add(new TestLogRecord<>( + loggerClass, + TestLogLevel.DEBUG, + messageSupplier.get(), + throwable + )); + } + + @Override + public void info(Supplier messageSupplier) { + logRecords.add(new TestLogRecord<>( + loggerClass, + TestLogLevel.INFO, + messageSupplier.get(), + null + )); + } + + @Override + public void info( + Throwable throwable, + Supplier messageSupplier) { + logRecords.add(new TestLogRecord<>( + loggerClass, + TestLogLevel.INFO, + messageSupplier.get(), + throwable + )); + } + + @Override + public void warning(Supplier messageSupplier) { + logRecords.add(new TestLogRecord<>( + loggerClass, + TestLogLevel.WARNING, + messageSupplier.get(), + null + )); + } + + @Override + public void warning( + Throwable throwable, + Supplier messageSupplier) { + logRecords.add(new TestLogRecord<>( + loggerClass, + TestLogLevel.WARNING, + messageSupplier.get(), + throwable + )); + } + + @Override + public void error(Supplier messageSupplier) { + logRecords.add(new TestLogRecord<>( + loggerClass, + TestLogLevel.ERROR, + messageSupplier.get(), + null + )); + } + + @Override + public void error( + Throwable throwable, + Supplier messageSupplier) { + logRecords.add(new TestLogRecord<>( + loggerClass, + TestLogLevel.ERROR, + messageSupplier.get(), + throwable + )); + } + + public List> getLogRecords() { + return logRecords; + } + + public Class getLoggerClass() { + return loggerClass; + } + + /** + * Clears the stored log records. Should be done after every unit test. + */ + public void clearRecords() { + logRecords.clear(); + } +} diff --git a/shared/src/testFixtures/java/com/fagi/logging/TestLoggerConfigurationStrategy.java b/shared/src/testFixtures/java/com/fagi/logging/TestLoggerConfigurationStrategy.java new file mode 100644 index 00000000..451231a2 --- /dev/null +++ b/shared/src/testFixtures/java/com/fagi/logging/TestLoggerConfigurationStrategy.java @@ -0,0 +1,35 @@ +package com.fagi.logging; + +import java.nio.file.Path; + +/** + * Used to help test logging + */ +public class TestLoggerConfigurationStrategy implements FagiLoggerConfigStrategy { + private final boolean customConfigurationAvailable; + private boolean configured = false; + private Path logFile; + + public TestLoggerConfigurationStrategy(boolean customConfigurationAvailable) { + this.customConfigurationAvailable = customConfigurationAvailable; + } + + @Override + public boolean isCustomConfigurationAvailable() { + return customConfigurationAvailable; + } + + @Override + public void setupDefaultConfiguration(Path logFile) { + configured = true; + this.logFile = logFile; + } + + public boolean isConfigured() { + return configured; + } + + public Path getLogFile() { + return logFile; + } +} diff --git a/shared/src/testFixtures/java/com/fagi/logging/TestLoggerCreationStrategy.java b/shared/src/testFixtures/java/com/fagi/logging/TestLoggerCreationStrategy.java new file mode 100644 index 00000000..7eea4ab1 --- /dev/null +++ b/shared/src/testFixtures/java/com/fagi/logging/TestLoggerCreationStrategy.java @@ -0,0 +1,22 @@ +package com.fagi.logging; + +import java.util.Map; + +/** + * Test creation strategy that stores created loggers to simulate the same behaviour as normal logger creation + */ +public class TestLoggerCreationStrategy implements FagiLoggerCreationStrategy { + private final Map, TestLogger> loggers; + + public TestLoggerCreationStrategy(Map, TestLogger> loggers) { + this.loggers = loggers; + } + + @Override + public FagiLogger createLogger(Class tClass) { + return loggers.computeIfAbsent( + tClass, + (k) -> new TestLogger<>(tClass) + ); + } +} From 84eae3e6841177440743cee44196b4f9428a2857 Mon Sep 17 00:00:00 2001 From: zargess Date: Thu, 17 Jul 2025 15:29:28 +0200 Subject: [PATCH 5/7] Using fagi logging framework The usage of System.out, System.err and exception.printStackTrace, has been replaced with the Fagi Logging framework. This is to allow for storing logs in files and better granularity in the level of logs. Also deleted the old Logger class that stored logs in a *.fagi file. --- .gitignore | 2 + fagiClient/build.gradle.kts | 2 + .../java/com/fagi/action/items/LoadFXML.java | 10 +- .../action/items/OpenConversationFromID.java | 5 +- .../java/com/fagi/controller/MainScreen.java | 97 +++++-- .../fagi/controller/login/MasterLogin.java | 17 +- .../src/main/java/com/fagi/main/FagiApp.java | 17 +- .../java/com/fagi/network/ChatManager.java | 18 +- .../java/com/fagi/network/Communication.java | 27 +- .../java/com/fagi/network/InputHandler.java | 17 +- .../handlers/DefaultThreadHandler.java | 4 +- .../fagi/network/handlers/GeneralHandler.java | 24 +- .../network/handlers/TextMessageHandler.java | 24 +- .../java/com/fagi/threads/ThreadPool.java | 4 - .../com/fagi/action/items/LoadFXMLTest.java | 3 +- .../com/fagi/guitests/ConversationTests.java | 3 +- .../fagi/guitests/CreatePasswordTests.java | 3 +- .../fagi/guitests/CreateUserNameTests.java | 3 +- .../java/com/fagi/guitests/FriendsTests.java | 3 +- .../com/fagi/guitests/InviteCodeTests.java | 3 +- .../java/com/fagi/guitests/LoginTests.java | 3 +- .../guitests/ReceiveFriendRequestTests.java | 3 +- .../com/fagi/guitests/SearchUserTests.java | 3 +- .../fagi/guitests/SendFriendRequestTests.java | 3 +- .../java/com/fagi/guitests/SignOutTests.java | 3 +- .../com/fagi/network/CommunicationTest.java | 3 +- .../java/com/fagi/util/FontUtilsTest.java | 3 +- fagiServer/build.gradle.kts | 2 + .../java/com/fagi/encryption/Encryption.java | 14 +- .../com/fagi/handler/ConversationHandler.java | 8 +- .../java/com/fagi/handler/InputHandler.java | 7 +- .../src/main/java/com/fagi/main/Main.java | 20 +- .../src/main/java/com/fagi/model/Data.java | 5 +- .../src/main/java/com/fagi/server/Server.java | 22 +- .../java/com/fagi/worker/InputWorker.java | 26 +- .../java/com/fagi/worker/OutputWorker.java | 15 +- .../java/TextMessageIntegrationTests.java | 3 +- .../com/fagi/encryption/EncryptionTests.java | 130 +++++++-- .../fagi/handler/ConversationHandlerTest.java | 49 +++- .../inputhandler/BaseInputHandlerTest.java | 3 +- .../inputhandler/NotValidRequestTests.java | 62 +++-- .../test/java/com/fagi/model/DataTests.java | 39 +-- .../test/java/com/fagi/model/UserTests.java | 3 +- .../ServerConversationHandlerTests.java | 2 +- .../com/fagi/server/ServerLoggingTests.java | 182 ++++++++----- .../java/com/fagi/server/ServerTests.java | 3 +- .../com/fagi/server/ServerWorkerTests.java | 2 +- .../util/{ => running}/NeverRunStrategy.java | 2 +- .../util/{ => running}/RunOnceStrategy.java | 2 +- .../com/fagi/worker/InputWorkerTests.java | 246 ++++++++++-------- .../com/fagi/worker/OutputWorkerTest.java | 196 ++++++++------ .../java/com/fagi/db/LogBasedDatabase.java | 104 ++++---- .../main/java/com/fagi/encryption/AES.java | 22 +- .../java/com/fagi/encryption/KeyStorage.java | 10 +- .../main/java/com/fagi/encryption/RSA.java | 28 +- .../main/java/com/fagi/utility/Checksum.java | 12 +- .../com/fagi/utility/JsonFileOperations.java | 54 +++- .../main/java/com/fagi/utility/Logger.java | 33 --- .../java/com/fagi/utility/NetworkUtility.java | 14 +- .../db/LogBasedDatabaseConcurrencyTests.java | 17 +- .../db/LogBasedDatabaseCorruptionTests.java | 66 +++-- .../db/LogBasedDatabaseIntegrationTests.java | 56 ++-- .../db/LogBasedDatabasePerformanceTests.java | 12 +- .../db/LogBasedDatabaseResourceTests.java | 3 +- .../com/fagi/db/LogBasedDatabaseTests.java | 3 +- 65 files changed, 1179 insertions(+), 605 deletions(-) rename fagiServer/src/test/java/com/fagi/util/{ => running}/NeverRunStrategy.java (90%) rename fagiServer/src/test/java/com/fagi/util/{ => running}/RunOnceStrategy.java (94%) delete mode 100644 shared/src/main/java/com/fagi/utility/Logger.java diff --git a/.gitignore b/.gitignore index 8dfbde68..0fc6d408 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ users *.key *.config *.fagi +*.log +*.log.lck .gradle/* **/build/* buildSrc/.gradle \ No newline at end of file diff --git a/fagiClient/build.gradle.kts b/fagiClient/build.gradle.kts index fc5a93e9..2d3851fb 100644 --- a/fagiClient/build.gradle.kts +++ b/fagiClient/build.gradle.kts @@ -18,6 +18,8 @@ javafx { dependencies { implementation(project(":shared")) + testImplementation(testFixtures(project(":shared"))) + testImplementation(libs.bundles.junit.base) testImplementation(libs.bundles.mockito) testImplementation(libs.hamcrest) diff --git a/fagiClient/src/main/java/com/fagi/action/items/LoadFXML.java b/fagiClient/src/main/java/com/fagi/action/items/LoadFXML.java index f8b94c0c..bde3ecb7 100644 --- a/fagiClient/src/main/java/com/fagi/action/items/LoadFXML.java +++ b/fagiClient/src/main/java/com/fagi/action/items/LoadFXML.java @@ -5,7 +5,8 @@ package com.fagi.action.items; import com.fagi.action.Action; -import com.fagi.utility.Logger; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; @@ -18,6 +19,7 @@ * @author miniwolf */ public record LoadFXML(String resourcePath) implements Action { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(LoadFXML.class); @Override public void execute(Parent parent) { @@ -27,8 +29,10 @@ public void execute(Parent parent) { try { loader.load(); } catch (IOException ioe) { - ioe.printStackTrace(); - Logger.logStackTrace(ioe); + LOGGER.error( + ioe, + () -> "Failed to load the FXML file: " + resourcePath + ); } } } diff --git a/fagiClient/src/main/java/com/fagi/action/items/OpenConversationFromID.java b/fagiClient/src/main/java/com/fagi/action/items/OpenConversationFromID.java index 4bbfcfe3..a4236bb9 100644 --- a/fagiClient/src/main/java/com/fagi/action/items/OpenConversationFromID.java +++ b/fagiClient/src/main/java/com/fagi/action/items/OpenConversationFromID.java @@ -6,6 +6,8 @@ import com.fagi.conversation.Conversation; import com.fagi.conversation.ConversationType; import com.fagi.conversation.GetAllConversationDataRequest; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.network.Communication; import java.util.Optional; @@ -14,6 +16,7 @@ * @author miniwolf */ public record OpenConversationFromID(MainScreen mainScreen) implements Action { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(OpenConversationFromID.class); @Override public void execute(Long id) { Optional optional = mainScreen @@ -58,7 +61,7 @@ private void addConversationToMain( } private void errorHandling(long id) { - System.err.println("OpenConversationFromID: Couldn't find conversation on ID <" + id + ">"); + LOGGER.error(() -> "OpenConversationFromID: Couldn't find conversation on ID <" + id + ">"); throw new RuntimeException(); } } diff --git a/fagiClient/src/main/java/com/fagi/controller/MainScreen.java b/fagiClient/src/main/java/com/fagi/controller/MainScreen.java index c9de5f4a..d8962ed8 100644 --- a/fagiClient/src/main/java/com/fagi/controller/MainScreen.java +++ b/fagiClient/src/main/java/com/fagi/controller/MainScreen.java @@ -16,6 +16,8 @@ import com.fagi.conversation.Conversation; import com.fagi.conversation.ConversationFilter; import com.fagi.handler.Search; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.model.GetFriendListRequest; import com.fagi.model.Logout; import com.fagi.model.conversation.GetConversationsRequest; @@ -30,7 +32,6 @@ import com.fagi.threads.ThreadPool; import com.fagi.uimodel.FriendMapWrapper; import com.fagi.utility.JsonFileOperations; -import com.fagi.utility.Logger; import javafx.application.Platform; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; @@ -60,6 +61,7 @@ * TODO: Write description. */ public class MainScreen extends Pane { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(MainScreen.class); @FXML private Pane messages; @FXML private Pane contacts; @FXML private ScrollPane listContent; @@ -139,11 +141,24 @@ public void initCommunication(ThreadPool threadPool) { setupFriendList(); setupContactList(); - messageHandler = new TextMessageHandler(this, communication.getInputDistributor()); - threadPool.startThread(messageHandler.getRunnable(), "MessageHandler"); + messageHandler = new TextMessageHandler( + this, + communication.getInputDistributor() + ); + threadPool.startThread( + messageHandler.getRunnable(), + "MessageHandler" + ); - generalHandler = new GeneralHandlerFactory().construct(this, communication.getInputDistributor(), threadPool); - threadPool.startThread(generalHandler.getRunnable(), "GeneralHandler"); + generalHandler = new GeneralHandlerFactory().construct( + this, + communication.getInputDistributor(), + threadPool + ); + threadPool.startThread( + generalHandler.getRunnable(), + "GeneralHandler" + ); updateConversationListFromServer(conversations); } @@ -161,15 +176,31 @@ private void initialize() { emptyFocusElement = messages; username.setText(usernameString); char cUpper = Character.toUpperCase(usernameString.toCharArray()[0]); - Image tiny = new Image("/style/material-icons/" + cUpper + ".png", 40, 40, true, true); + Image tiny = new Image( + "/style/material-icons/" + cUpper + ".png", + 40, + 40, + true, + true + ); this.tinyIcon.setImage(tiny); - Image large = new Image("/style/material-icons/" + cUpper + ".png", 96, 96, true, true); + Image large = new Image( + "/style/material-icons/" + cUpper + ".png", + 96, + 96, + true, + true + ); this.largeIcon.setImage(large); this.requestFocus(); Scene scene = primaryStage.getScene(); final MainScreen mainScreen = this; - Platform.runLater(() -> search = new Search(searchBox, searchHeader, mainScreen)); + Platform.runLater(() -> search = new Search( + searchBox, + searchHeader, + mainScreen + )); scene .widthProperty() .addListener(new ChangeListener<>() { @@ -183,14 +214,20 @@ public void changed( try { Thread.sleep(1000); } catch (InterruptedException e) { - e.printStackTrace(); - Logger.logStackTrace(e); + LOGGER.info(() -> "Search thread was interrupted."); } finally { - Platform.runLater(() -> search = new Search(searchBox, searchHeader, mainScreen)); + Platform.runLater(() -> search = new Search( + searchBox, + searchHeader, + mainScreen + )); } }; - threadPool.startThread(run, "Search thread"); + threadPool.startThread( + run, + "Search thread" + ); scene .widthProperty() @@ -281,7 +318,10 @@ public void setScrollPaneContent( Platform.runLater(() -> listContent.setContent(parent)); } - listContentMap.put(content, parent); + listContentMap.put( + content, + parent + ); } public void setFriendList(FriendList friendList) { @@ -318,7 +358,7 @@ public void changeMenuStyle(String menu) { currentPane = messages; } default -> { - System.err.println("Mainscreen, changeMenuStyle: " + menu); + LOGGER.error(() -> "Failed to change MainScreen menu into unsupported menu type: " + menu); throw new UnsupportedOperationException(); } } @@ -364,10 +404,16 @@ public Parent getListContent(PaneContent content) { private void updateConversationListFromServer(List conversations) { List filters = conversations .stream() - .map(x -> new ConversationFilter(x.getId(), x.getLastMessageDate())) + .map(x -> new ConversationFilter( + x.getId(), + x.getLastMessageDate() + )) .collect(Collectors.toList()); - communication.sendObject(new GetConversationsRequest(usernameString, filters)); + communication.sendObject(new GetConversationsRequest( + usernameString, + filters + )); } private void setupFriendList() { @@ -376,12 +422,18 @@ private void setupFriendList() { private void setupContactList() { ContentController contactContentController = new ContentController("/view/content/ContentList.fxml"); - setScrollPaneContent(PaneContent.Contacts, contactContentController); + setScrollPaneContent( + PaneContent.Contacts, + contactContentController + ); } private synchronized void setupConversationList() { conversationContentController = new ContentController("/view/content/ContentList.fxml"); - setScrollPaneContent(PaneContent.Messages, conversationContentController); + setScrollPaneContent( + PaneContent.Messages, + conversationContentController + ); messageItems.forEach(MessageItemController::stopTimer); messageItems.clear(); @@ -391,10 +443,11 @@ private synchronized void setupConversationList() { } public Pane createMessageItem(Conversation conversation) { - MessageItemController messageItemController = new MessageItemController(usernameString, - conversation, - new OpenConversationFromID(this), - conversation.getLastMessageDate() + MessageItemController messageItemController = new MessageItemController( + usernameString, + conversation, + new OpenConversationFromID(this), + conversation.getLastMessageDate() ); messageItems.add(messageItemController); return messageItemController; diff --git a/fagiClient/src/main/java/com/fagi/controller/login/MasterLogin.java b/fagiClient/src/main/java/com/fagi/controller/login/MasterLogin.java index e4ff756b..0aed6de4 100644 --- a/fagiClient/src/main/java/com/fagi/controller/login/MasterLogin.java +++ b/fagiClient/src/main/java/com/fagi/controller/login/MasterLogin.java @@ -6,6 +6,8 @@ import com.fagi.controller.utility.Draggable; import com.fagi.enums.LoginState; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.main.FagiApp; import com.fagi.network.ChatManager; import com.fagi.network.Communication; @@ -21,6 +23,7 @@ * class. */ public class MasterLogin { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(MasterLogin.class); private final FagiApp fagiApp; private final Communication communication; private final Draggable draggable; @@ -63,7 +66,10 @@ public void handleQuit() { ChatManager.closeCommunication(); fagiApp.stop(); } catch (Exception ex) { - System.err.println(ex.toString()); + LOGGER.error( + ex, + () -> "Client encountered unexpected exception whilst attempting to shutdown." + ); } } @@ -90,7 +96,7 @@ public void next() { case PASSWORD -> state = LoginState.INVITE_CODE; case INVITE_CODE -> state = LoginState.LOGIN; default -> { - System.out.println(state + " is not known"); + LOGGER.error(() -> state + " is not known"); throw new UnsupportedOperationException(); } } @@ -115,7 +121,7 @@ public void back() { state = LoginState.PASSWORD; break; default: - System.out.println(state + " is not known"); + LOGGER.error(() -> state + " is not known"); throw new UnsupportedOperationException(); } showScreen(state); @@ -140,7 +146,10 @@ private boolean setupController(LoginState screen) { LoginController controller; switch (screen) { case LOGIN: - controller = new LoginScreenController(this, communication); + controller = new LoginScreenController( + this, + communication + ); break; case USERNAME: controller = new CreateUserNameController(this); diff --git a/fagiClient/src/main/java/com/fagi/main/FagiApp.java b/fagiClient/src/main/java/com/fagi/main/FagiApp.java index c2ae7481..ea9489ee 100644 --- a/fagiClient/src/main/java/com/fagi/main/FagiApp.java +++ b/fagiClient/src/main/java/com/fagi/main/FagiApp.java @@ -8,10 +8,11 @@ import com.fagi.controller.login.MasterLogin; import com.fagi.controller.utility.Draggable; import com.fagi.encryption.AES; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.network.ChatManager; import com.fagi.network.Communication; import com.fagi.threads.ThreadPool; -import com.fagi.utility.Logger; import javafx.application.Application; import javafx.application.Platform; import javafx.scene.Scene; @@ -20,12 +21,14 @@ import javafx.stage.StageStyle; import java.io.IOException; +import java.nio.file.Path; import java.util.concurrent.atomic.AtomicBoolean; /** * JavaFX application class for handling GUI. */ public class FagiApp extends Application { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(FagiApp.class); private Stage primaryStage; private Scene scene; private ThreadPool threadPool; @@ -36,8 +39,12 @@ public class FagiApp extends Application { * @param args the command line arguments */ public static void main(String[] args) { + if (!FagiLoggerFactory.isCustomConfigurationAvailable()) { + FagiLoggerFactory.setupDefaultConfiguration(Path.of("client.log")); + LOGGER.info(() -> "No log config file specified. Using default log config instead."); + } if (args.length != 0) { - System.out.println("Usage: java LoginScreen"); + LOGGER.info(() -> "Usage: java LoginScreen"); } launch(args); } @@ -80,8 +87,10 @@ private void startCommunication( successfulConnection.set(true); } catch (IOException e) { Platform.runLater(() -> masterLogin.setMessageLabel("Connection refused")); - e.printStackTrace(); - Logger.logStackTrace(e); + LOGGER.error( + e, + () -> "Failed to connect to the server." + ); } }); diff --git a/fagiClient/src/main/java/com/fagi/network/ChatManager.java b/fagiClient/src/main/java/com/fagi/network/ChatManager.java index c8401d37..473fbea0 100644 --- a/fagiClient/src/main/java/com/fagi/network/ChatManager.java +++ b/fagiClient/src/main/java/com/fagi/network/ChatManager.java @@ -4,6 +4,8 @@ * ChatManager.java */ +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.main.FagiApp; import com.fagi.model.CreateUser; import com.fagi.model.InviteCode; @@ -24,6 +26,7 @@ * Handles login requests and responds to and from server. */ public class ChatManager { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(ChatManager.class); private static Communication communication = null; private static FagiApp application; private ServiceLoader communicationLoader; @@ -45,7 +48,7 @@ public static void handleLogout(Logout logout) { communication.sendObject(logout); Response response = communication.getNextResponse(); if (!(response instanceof AllIsWell)) { - System.err.println("Could not log out properly. " + "Shut down and let server handle the response"); + LOGGER.error(() -> "Could not log out properly. Shut down and let server handle the response."); } communication.close(); application.showLoginScreen(); @@ -79,12 +82,16 @@ public static boolean handleCreateUser( } if (!isValidUserName(username)) { - System.out.println(username); + LOGGER.info(() -> "The username " + username + " is not valid."); labelMessage.setText("Username may not contain special symbols"); return false; } - CreateUser createUser = new CreateUser(username, password, new InviteCode(inviteCode)); + CreateUser createUser = new CreateUser( + username, + password, + new InviteCode(inviteCode) + ); communication.sendObject(createUser); Response response = communication.getNextResponse(); @@ -128,7 +135,10 @@ public static void setApplication(FagiApp application) { } public static boolean isValidUserName(String string) { - return Pattern.matches("\\w*", string); + return Pattern.matches( + "\\w*", + string + ); } public static FagiApp getApplication() { diff --git a/fagiClient/src/main/java/com/fagi/network/Communication.java b/fagiClient/src/main/java/com/fagi/network/Communication.java index b39156e2..28b4dbf0 100644 --- a/fagiClient/src/main/java/com/fagi/network/Communication.java +++ b/fagiClient/src/main/java/com/fagi/network/Communication.java @@ -13,12 +13,13 @@ import com.fagi.encryption.EncryptionAlgorithm; import com.fagi.encryption.RSA; import com.fagi.encryption.RSAKey; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.model.HistoryUpdates; import com.fagi.model.Session; import com.fagi.responses.AllIsWell; import com.fagi.responses.Response; import com.fagi.threads.ThreadPool; -import com.fagi.utility.Logger; import java.io.IOException; import java.io.ObjectInputStream; @@ -32,6 +33,7 @@ * TODO: Add description */ public class Communication { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(Communication.class); private ObjectOutputStream out; private InputHandler inputHandler; private Socket socket; @@ -76,12 +78,11 @@ public void connect( encryption, serverKey ); - } catch (UnknownHostException Uhe) { - System.err.println("c Uhe: " + Uhe); - Logger.logStackTrace(Uhe); - } catch (IOException ioe) { - Logger.logStackTrace(ioe); - throw new IOException("c ioe: " + ioe.toString()); + } catch (UnknownHostException uhe) { + LOGGER.error( + uhe, + () -> "Failed to connect to the following host: " + host + ); } } @@ -123,9 +124,10 @@ public void sendObject(Object obj) { out.writeObject(encryption.encrypt(Conversion.convertToBytes(obj))); out.flush(); } catch (IOException e) { - System.err.println("cso ioe: " + e.toString()); - e.printStackTrace(); - Logger.logStackTrace(e); + LOGGER.error( + e, + () -> "Lost connection to server. Crashing now." + ); System.exit(1); } } @@ -136,7 +138,10 @@ public void close() { try { socket.close(); } catch (IOException ioe) { - System.err.println("cc ioe: " + ioe.toString()); + LOGGER.error( + ioe, + () -> "Failed to close Communication gracefully." + ); } } diff --git a/fagiClient/src/main/java/com/fagi/network/InputHandler.java b/fagiClient/src/main/java/com/fagi/network/InputHandler.java index 234602d6..b30d4bd1 100644 --- a/fagiClient/src/main/java/com/fagi/network/InputHandler.java +++ b/fagiClient/src/main/java/com/fagi/network/InputHandler.java @@ -8,11 +8,12 @@ import com.fagi.conversation.Conversation; import com.fagi.encryption.Conversion; import com.fagi.encryption.EncryptionAlgorithm; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.model.HistoryUpdates; import com.fagi.model.messages.InGoingMessages; import com.fagi.responses.Response; import com.fagi.threads.ThreadPool; -import com.fagi.utility.Logger; import javafx.application.Platform; import java.io.IOException; @@ -25,6 +26,7 @@ * TODO: Write description */ public class InputHandler implements Runnable { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(InputHandler.class); private final Queue inputs = new LinkedBlockingQueue<>(); private final ObjectInputStream in; private final EncryptionAlgorithm encryption; @@ -53,9 +55,10 @@ public void run() { handleInput(Conversion.convertFromBytes(encryption.decrypt(input))); } catch (IOException ioe) { if (running) { - System.err.println("inputhandler ioe: " + ioe.toString()); - ioe.printStackTrace(); // DEBUG need to terminate before closing socket. - Logger.logStackTrace(ioe); + LOGGER.error( + ioe, + () -> "Failed to receive data from server. Logging user out." + ); } running = false; ChatManager.closeCommunication(); @@ -63,9 +66,11 @@ public void run() { .getApplication() .showLoginScreen()); } catch (ClassNotFoundException cnfe) { + LOGGER.error( + cnfe, + () -> "Failed to parse response data from server." + ); // Shared files are not the same on both side of the server - System.err.println(cnfe.getMessage()); - Logger.logStackTrace(cnfe); // TODO: This will be a bitch when having to update the server // Fix: could be to implement JSON } diff --git a/fagiClient/src/main/java/com/fagi/network/handlers/DefaultThreadHandler.java b/fagiClient/src/main/java/com/fagi/network/handlers/DefaultThreadHandler.java index 81539fff..06984e86 100644 --- a/fagiClient/src/main/java/com/fagi/network/handlers/DefaultThreadHandler.java +++ b/fagiClient/src/main/java/com/fagi/network/handlers/DefaultThreadHandler.java @@ -7,11 +7,13 @@ import com.fagi.network.handlers.container.Container; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Logger; /** * @author miniwolf */ public class DefaultThreadHandler implements Runnable { + private static final Logger LOGGER = Logger.getLogger(DefaultThreadHandler.class.getName()); private Container container; private Handler handler; private AtomicBoolean running = new AtomicBoolean(true); @@ -39,7 +41,7 @@ public void run() { } } catch (InterruptedException e) { running.set(false); - System.out.println("Stopped the thread handler"); + LOGGER.info(() -> "Stopped the thread handler"); } } } diff --git a/fagiClient/src/main/java/com/fagi/network/handlers/GeneralHandler.java b/fagiClient/src/main/java/com/fagi/network/handlers/GeneralHandler.java index d0113198..129ea653 100644 --- a/fagiClient/src/main/java/com/fagi/network/handlers/GeneralHandler.java +++ b/fagiClient/src/main/java/com/fagi/network/handlers/GeneralHandler.java @@ -9,17 +9,22 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; /** * Created by Marcus on 08-07-2016. */ public class GeneralHandler implements Handler { + private static final Logger LOGGER = Logger.getLogger(GeneralHandler.class.getName()); private final Map> handlers = new ConcurrentHashMap<>(); private final Container container = new DefaultContainer<>(); private final InputDistributor inputDistributor; private final List unhandledObjects = new ArrayList<>(); private final ThreadPool threadPool; - private DefaultThreadHandler runnable = new DefaultThreadHandler<>(container, this); + private DefaultThreadHandler runnable = new DefaultThreadHandler<>( + container, + this + ); public GeneralHandler( InputDistributor inputDistributor, @@ -34,19 +39,28 @@ public void handle(T object) { Handler handler = handlers.get(object.getClass()); if (handler == null) { - System.err.println("Missing handler: " + object.getClass()); + LOGGER.severe(() -> "Missing handler: " + object.getClass()); unhandledObjects.add(object); return; } - threadPool.startThread(() -> handler.handle(object), "GeneralHandler: " + object.getClass()); + threadPool.startThread( + () -> handler.handle(object), + "GeneralHandler: " + object.getClass() + ); } public void registerHandler( Class clazz, Handler handler) { - handlers.put(clazz, handler); - inputDistributor.register(clazz, container); + handlers.put( + clazz, + handler + ); + inputDistributor.register( + clazz, + container + ); } @Override diff --git a/fagiClient/src/main/java/com/fagi/network/handlers/TextMessageHandler.java b/fagiClient/src/main/java/com/fagi/network/handlers/TextMessageHandler.java index d8a573dc..4bd48830 100644 --- a/fagiClient/src/main/java/com/fagi/network/handlers/TextMessageHandler.java +++ b/fagiClient/src/main/java/com/fagi/network/handlers/TextMessageHandler.java @@ -18,20 +18,28 @@ import javafx.application.Platform; import java.util.Optional; +import java.util.logging.Logger; /** * @author miniwolf */ public class TextMessageHandler implements Handler { + private static final Logger LOGGER = Logger.getLogger(TextMessageHandler.class.getName()); private Container container = new DefaultContainer<>(); - private DefaultThreadHandler runnable = new DefaultThreadHandler<>(container, this); + private DefaultThreadHandler runnable = new DefaultThreadHandler<>( + container, + this + ); private final MainScreen mainScreen; public TextMessageHandler( MainScreen mainScreen, InputDistributor inputDistributor) { container.setThread(runnable); - inputDistributor.register(TextMessage.class, container); + inputDistributor.register( + TextMessage.class, + container + ); this.mainScreen = mainScreen; } @@ -45,7 +53,7 @@ public void handle(TextMessage message) { .getConversationID()) .findFirst(); if (first.isEmpty()) { - System.err.println("Server sent a message before it sent the conversation of ID '" + message + LOGGER.severe(() -> "Server sent a message before it sent the conversation of ID '" + message .getMessageInfo() .getConversationID() + "'to the profile."); return; @@ -60,7 +68,10 @@ public void handle(TextMessage message) { conversation.setType(type); mainScreen .getCommunication() - .sendObject(new GetAllConversationDataRequest(mainScreen.getUsername(), conversation.getId())); + .sendObject(new GetAllConversationDataRequest( + mainScreen.getUsername(), + conversation.getId() + )); } if (mainScreen.hasCurrentOpenConversation(conversation)) { @@ -71,7 +82,10 @@ public void handle(TextMessage message) { conversation .data() .addMessage(message); - JsonFileOperations.storeClientConversation(conversation, mainScreen.getUsername()); + JsonFileOperations.storeClientConversation( + conversation, + mainScreen.getUsername() + ); MessageItemController messageItemController = mainScreen .getMessageItems() diff --git a/fagiClient/src/main/java/com/fagi/threads/ThreadPool.java b/fagiClient/src/main/java/com/fagi/threads/ThreadPool.java index 75927256..f504a139 100644 --- a/fagiClient/src/main/java/com/fagi/threads/ThreadPool.java +++ b/fagiClient/src/main/java/com/fagi/threads/ThreadPool.java @@ -42,8 +42,4 @@ public void stopThreads() { threads.forEach(Thread::interrupt); threads.clear(); } - - public void printThreads() { - threads.forEach(thread -> System.out.println("Thread: " + thread.getName() + " alive: " + thread.isAlive())); - } } diff --git a/fagiClient/src/test/java/com/fagi/action/items/LoadFXMLTest.java b/fagiClient/src/test/java/com/fagi/action/items/LoadFXMLTest.java index b483160c..6fb82922 100644 --- a/fagiClient/src/test/java/com/fagi/action/items/LoadFXMLTest.java +++ b/fagiClient/src/test/java/com/fagi/action/items/LoadFXMLTest.java @@ -1,5 +1,6 @@ package com.fagi.action.items; +import com.fagi.BaseFagiTest; import com.fagi.controller.MainScreen; import com.fagi.controller.conversation.ConversationController; import com.fagi.conversation.Conversation; @@ -14,7 +15,7 @@ * Created by miniwolf on 01-04-2017. */ @ExtendWith(JavaFXThreadingExtension.class) -public class LoadFXMLTest { +public class LoadFXMLTest extends BaseFagiTest { private LoadFXML loadFXML; private ConversationController mock; diff --git a/fagiClient/src/test/java/com/fagi/guitests/ConversationTests.java b/fagiClient/src/test/java/com/fagi/guitests/ConversationTests.java index 27003622..19093e57 100644 --- a/fagiClient/src/test/java/com/fagi/guitests/ConversationTests.java +++ b/fagiClient/src/test/java/com/fagi/guitests/ConversationTests.java @@ -1,5 +1,6 @@ package com.fagi.guitests; +import com.fagi.BaseFagiTest; import com.fagi.controller.MainScreen; import com.fagi.controller.login.MasterLogin; import com.fagi.controller.utility.Draggable; @@ -44,7 +45,7 @@ import java.util.List; @ExtendWith(ApplicationExtension.class) -public class ConversationTests { +public class ConversationTests extends BaseFagiTest { private InputHandler inputHandler; private Communication communication; diff --git a/fagiClient/src/test/java/com/fagi/guitests/CreatePasswordTests.java b/fagiClient/src/test/java/com/fagi/guitests/CreatePasswordTests.java index 220c1d1f..51a0239a 100644 --- a/fagiClient/src/test/java/com/fagi/guitests/CreatePasswordTests.java +++ b/fagiClient/src/test/java/com/fagi/guitests/CreatePasswordTests.java @@ -1,5 +1,6 @@ package com.fagi.guitests; +import com.fagi.BaseFagiTest; import com.fagi.controller.login.MasterLogin; import com.fagi.controller.utility.Draggable; import com.fagi.enums.LoginState; @@ -25,7 +26,7 @@ import org.testfx.matcher.base.NodeMatchers; @ExtendWith(ApplicationExtension.class) -public class CreatePasswordTests { +public class CreatePasswordTests extends BaseFagiTest { private MasterLogin masterLogin; @BeforeAll diff --git a/fagiClient/src/test/java/com/fagi/guitests/CreateUserNameTests.java b/fagiClient/src/test/java/com/fagi/guitests/CreateUserNameTests.java index 604364d8..fc971c1e 100644 --- a/fagiClient/src/test/java/com/fagi/guitests/CreateUserNameTests.java +++ b/fagiClient/src/test/java/com/fagi/guitests/CreateUserNameTests.java @@ -1,5 +1,6 @@ package com.fagi.guitests; +import com.fagi.BaseFagiTest; import com.fagi.controller.login.MasterLogin; import com.fagi.controller.utility.Draggable; import com.fagi.enums.LoginState; @@ -28,7 +29,7 @@ import org.testfx.matcher.base.NodeMatchers; @ExtendWith(ApplicationExtension.class) -public class CreateUserNameTests { +public class CreateUserNameTests extends BaseFagiTest { private Communication communication; private MasterLogin masterLogin; diff --git a/fagiClient/src/test/java/com/fagi/guitests/FriendsTests.java b/fagiClient/src/test/java/com/fagi/guitests/FriendsTests.java index c1b7615c..aca3a1a8 100644 --- a/fagiClient/src/test/java/com/fagi/guitests/FriendsTests.java +++ b/fagiClient/src/test/java/com/fagi/guitests/FriendsTests.java @@ -1,5 +1,6 @@ package com.fagi.guitests; +import com.fagi.BaseFagiTest; import com.fagi.controller.MainScreen; import com.fagi.controller.login.MasterLogin; import com.fagi.controller.utility.Draggable; @@ -44,7 +45,7 @@ import static org.hamcrest.collection.IsIterableContainingInOrder.contains; @ExtendWith(ApplicationExtension.class) -public class FriendsTests { +public class FriendsTests extends BaseFagiTest { private InputHandler inputHandler; private final ThreadPool threadPool = new ThreadPool(); diff --git a/fagiClient/src/test/java/com/fagi/guitests/InviteCodeTests.java b/fagiClient/src/test/java/com/fagi/guitests/InviteCodeTests.java index 1057dbbf..b550d407 100644 --- a/fagiClient/src/test/java/com/fagi/guitests/InviteCodeTests.java +++ b/fagiClient/src/test/java/com/fagi/guitests/InviteCodeTests.java @@ -1,5 +1,6 @@ package com.fagi.guitests; +import com.fagi.BaseFagiTest; import com.fagi.controller.login.MasterLogin; import com.fagi.controller.utility.Draggable; import com.fagi.enums.LoginState; @@ -26,7 +27,7 @@ import org.testfx.framework.junit5.Start; @ExtendWith(ApplicationExtension.class) -public class InviteCodeTests { +public class InviteCodeTests extends BaseFagiTest { private Communication communication; private MasterLogin masterLogin; diff --git a/fagiClient/src/test/java/com/fagi/guitests/LoginTests.java b/fagiClient/src/test/java/com/fagi/guitests/LoginTests.java index 2dc54ce6..5bae185a 100644 --- a/fagiClient/src/test/java/com/fagi/guitests/LoginTests.java +++ b/fagiClient/src/test/java/com/fagi/guitests/LoginTests.java @@ -1,5 +1,6 @@ package com.fagi.guitests; +import com.fagi.BaseFagiTest; import com.fagi.controller.login.MasterLogin; import com.fagi.controller.utility.Draggable; import com.fagi.enums.LoginState; @@ -30,7 +31,7 @@ import org.testfx.util.WaitForAsyncUtils; @ExtendWith(ApplicationExtension.class) -public class LoginTests { +public class LoginTests extends BaseFagiTest { private MasterLogin masterLogin; private Communication communication; diff --git a/fagiClient/src/test/java/com/fagi/guitests/ReceiveFriendRequestTests.java b/fagiClient/src/test/java/com/fagi/guitests/ReceiveFriendRequestTests.java index 5322acfb..aad32805 100644 --- a/fagiClient/src/test/java/com/fagi/guitests/ReceiveFriendRequestTests.java +++ b/fagiClient/src/test/java/com/fagi/guitests/ReceiveFriendRequestTests.java @@ -1,5 +1,6 @@ package com.fagi.guitests; +import com.fagi.BaseFagiTest; import com.fagi.controller.MainScreen; import com.fagi.controller.login.MasterLogin; import com.fagi.controller.utility.Draggable; @@ -34,7 +35,7 @@ import static com.fagi.helpers.WaitForFXEventsTestHelper.addIngoingMessageToInputHandler; @ExtendWith(ApplicationExtension.class) -public class ReceiveFriendRequestTests { +public class ReceiveFriendRequestTests extends BaseFagiTest { private static final String myUsername = "Test"; private Communication communication; private InputHandler inputHandler; diff --git a/fagiClient/src/test/java/com/fagi/guitests/SearchUserTests.java b/fagiClient/src/test/java/com/fagi/guitests/SearchUserTests.java index 00352239..e72237b1 100644 --- a/fagiClient/src/test/java/com/fagi/guitests/SearchUserTests.java +++ b/fagiClient/src/test/java/com/fagi/guitests/SearchUserTests.java @@ -1,5 +1,6 @@ package com.fagi.guitests; +import com.fagi.BaseFagiTest; import com.fagi.controller.MainScreen; import com.fagi.controller.login.MasterLogin; import com.fagi.controller.utility.Draggable; @@ -46,7 +47,7 @@ import static com.fagi.helpers.WaitForFXEventsTestHelper.addIngoingMessageToInputHandler; @ExtendWith(ApplicationExtension.class) -public class SearchUserTests { +public class SearchUserTests extends BaseFagiTest { private Communication communication; private InputHandler inputHandler; private final ThreadPool threadPool = new ThreadPool(); diff --git a/fagiClient/src/test/java/com/fagi/guitests/SendFriendRequestTests.java b/fagiClient/src/test/java/com/fagi/guitests/SendFriendRequestTests.java index 5184cb3a..7a77b46d 100644 --- a/fagiClient/src/test/java/com/fagi/guitests/SendFriendRequestTests.java +++ b/fagiClient/src/test/java/com/fagi/guitests/SendFriendRequestTests.java @@ -1,5 +1,6 @@ package com.fagi.guitests; +import com.fagi.BaseFagiTest; import com.fagi.controller.MainScreen; import com.fagi.controller.login.MasterLogin; import com.fagi.controller.utility.Draggable; @@ -35,7 +36,7 @@ import static com.fagi.helpers.WaitForFXEventsTestHelper.addIngoingMessageToInputHandler; @ExtendWith(ApplicationExtension.class) -public class SendFriendRequestTests { +public class SendFriendRequestTests extends BaseFagiTest { private Communication communication; private InputHandler inputHandler; private final ThreadPool threadPool = new ThreadPool(); diff --git a/fagiClient/src/test/java/com/fagi/guitests/SignOutTests.java b/fagiClient/src/test/java/com/fagi/guitests/SignOutTests.java index 6b23d73e..09874402 100644 --- a/fagiClient/src/test/java/com/fagi/guitests/SignOutTests.java +++ b/fagiClient/src/test/java/com/fagi/guitests/SignOutTests.java @@ -1,5 +1,6 @@ package com.fagi.guitests; +import com.fagi.BaseFagiTest; import com.fagi.controller.MainScreen; import com.fagi.controller.login.MasterLogin; import com.fagi.controller.utility.Draggable; @@ -26,7 +27,7 @@ import org.testfx.matcher.base.NodeMatchers; @ExtendWith(ApplicationExtension.class) -public class SignOutTests { +public class SignOutTests extends BaseFagiTest { private final ThreadPool threadPool = new ThreadPool(); @BeforeAll diff --git a/fagiClient/src/test/java/com/fagi/network/CommunicationTest.java b/fagiClient/src/test/java/com/fagi/network/CommunicationTest.java index 9fe8838b..56bb45f5 100644 --- a/fagiClient/src/test/java/com/fagi/network/CommunicationTest.java +++ b/fagiClient/src/test/java/com/fagi/network/CommunicationTest.java @@ -1,5 +1,6 @@ package com.fagi.network; +import com.fagi.BaseFagiTest; import com.fagi.encryption.Conversion; import com.fagi.encryption.EncryptionAlgorithm; import org.junit.jupiter.api.BeforeEach; @@ -13,7 +14,7 @@ * * @author miniwolf */ -public class CommunicationTest { +public class CommunicationTest extends BaseFagiTest { private Communication communication; @BeforeEach diff --git a/fagiClient/src/test/java/com/fagi/util/FontUtilsTest.java b/fagiClient/src/test/java/com/fagi/util/FontUtilsTest.java index ead62759..b1a74e0e 100644 --- a/fagiClient/src/test/java/com/fagi/util/FontUtilsTest.java +++ b/fagiClient/src/test/java/com/fagi/util/FontUtilsTest.java @@ -1,5 +1,6 @@ package com.fagi.util; +import com.fagi.BaseFagiTest; import javafx.scene.text.Font; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -34,7 +35,7 @@ private boolean isWindows() { * @author miniwolf */ @ExtendWith({JavaFXThreadingExtension.class, DisableOnLinuxAndMacCondition.class}) -public class FontUtilsTest { +public class FontUtilsTest extends BaseFagiTest { private static final Font ROBOTO = new Font("Roboto-Regular", 13); @Test diff --git a/fagiServer/build.gradle.kts b/fagiServer/build.gradle.kts index 22655d3c..d5224630 100644 --- a/fagiServer/build.gradle.kts +++ b/fagiServer/build.gradle.kts @@ -12,6 +12,8 @@ tasks.test { dependencies { implementation(project(":shared")) + + testImplementation(testFixtures(project(":shared"))) testImplementation(libs.bundles.junit.base) testImplementation(libs.bundles.mockito) } diff --git a/fagiServer/src/main/java/com/fagi/encryption/Encryption.java b/fagiServer/src/main/java/com/fagi/encryption/Encryption.java index 91bea5e5..fabe5db7 100644 --- a/fagiServer/src/main/java/com/fagi/encryption/Encryption.java +++ b/fagiServer/src/main/java/com/fagi/encryption/Encryption.java @@ -1,5 +1,8 @@ package com.fagi.encryption; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; + import java.io.File; import java.io.IOException; import java.security.KeyPair; @@ -10,6 +13,7 @@ * Created by Marcus on 04-06-2016. */ public class Encryption { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(Encryption.class); private static Encryption instance; private RSA rsa; @@ -24,14 +28,20 @@ public class Encryption { try { KeyStorage.SaveKeyPair(key); } catch (IOException e) { - e.printStackTrace(); + LOGGER.error( + e, + () -> "Could not save RSA key pair." + ); } } else { try { KeyPair key = KeyStorage.LoadKeyPair("RSA"); this.rsa = new RSA(key); } catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e) { - e.printStackTrace(); + LOGGER.error( + e, + () -> "Could not load RSA key pair." + ); } } diff --git a/fagiServer/src/main/java/com/fagi/handler/ConversationHandler.java b/fagiServer/src/main/java/com/fagi/handler/ConversationHandler.java index a818a73f..4374d4bb 100644 --- a/fagiServer/src/main/java/com/fagi/handler/ConversationHandler.java +++ b/fagiServer/src/main/java/com/fagi/handler/ConversationHandler.java @@ -1,6 +1,8 @@ package com.fagi.handler; import com.fagi.conversation.Conversation; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.model.Data; import com.fagi.model.messages.message.TextMessage; @@ -10,6 +12,7 @@ * Created by Marcus on 04-07-2016. */ public class ConversationHandler implements Runnable { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(ConversationHandler.class); private final Data data; private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(); @@ -44,7 +47,10 @@ public void tick() { data.storeConversation(conversation); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - ie.printStackTrace(); + LOGGER.debug( + ie, + () -> "Interrupted ConversationHandler thread." + ); } } diff --git a/fagiServer/src/main/java/com/fagi/handler/InputHandler.java b/fagiServer/src/main/java/com/fagi/handler/InputHandler.java index 3d09fa62..1b8abfe4 100644 --- a/fagiServer/src/main/java/com/fagi/handler/InputHandler.java +++ b/fagiServer/src/main/java/com/fagi/handler/InputHandler.java @@ -4,6 +4,8 @@ import com.fagi.conversation.ConversationDataUpdate; import com.fagi.conversation.GetAllConversationDataRequest; import com.fagi.encryption.AES; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.model.CreateUser; import com.fagi.model.Data; import com.fagi.model.DeleteFriend; @@ -50,9 +52,10 @@ import java.util.stream.Collectors; public record InputHandler(InputAgent inputAgent, OutputAgent out, ConversationHandler conversationHandler, Data data) { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(InputHandler.class); public void handleInput(Object input) { if (input == null) { - System.out.println("Input is null. Doing nothing."); + LOGGER.info(() -> "Input is null. Doing nothing."); } else if (input instanceof TextMessage arg) { MessageInfo messageInfo = arg.getMessageInfo(); messageInfo.setTimestamp(new Timestamp(System.currentTimeMillis())); @@ -102,7 +105,7 @@ public void handleInput(Object input) { } else if (input instanceof UserNameAvailableRequest request) { out.addResponse(handleUserNameAvailableRequest(request)); } else { - System.out.println("Unknown handle: " + input.getClass()); + LOGGER.info(() -> "Unknown handle: " + input.getClass()); } } diff --git a/fagiServer/src/main/java/com/fagi/main/Main.java b/fagiServer/src/main/java/com/fagi/main/Main.java index 5021a448..ca402ee5 100644 --- a/fagiServer/src/main/java/com/fagi/main/Main.java +++ b/fagiServer/src/main/java/com/fagi/main/Main.java @@ -4,30 +4,46 @@ * Main.java */ +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.model.Data; import com.fagi.server.Server; import java.io.IOException; import java.net.ServerSocket; +import java.nio.file.Path; /** * Handling server start. */ class Main { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(Main.class); + public static void main(String[] args) { Data data = new Data(); data.loadUsers(); int port = args.length > 0 ? Integer.parseInt(args[0]) : 4242; - Server server = new Server(port, data); + Server server = new Server( + port, + data + ); + + if (!FagiLoggerFactory.isCustomConfigurationAvailable()) { + FagiLoggerFactory.setupDefaultConfiguration(Path.of("server.log")); + LOGGER.info(() -> "No log config file specified. Using default log config instead."); + } try { var serverSocket = new ServerSocket(port); server.start(serverSocket); } catch (IOException e) { - System.out.println("Error while creating socket, are you sure you can use port " + port + " on you system?"); + LOGGER.error( + e, + () -> "Error while creating socket, are you sure you can use port " + port + " on you system?" + ); } } } \ No newline at end of file diff --git a/fagiServer/src/main/java/com/fagi/model/Data.java b/fagiServer/src/main/java/com/fagi/model/Data.java index 1b5ff75e..4737f195 100644 --- a/fagiServer/src/main/java/com/fagi/model/Data.java +++ b/fagiServer/src/main/java/com/fagi/model/Data.java @@ -6,6 +6,8 @@ import com.fagi.conversation.Conversation; import com.fagi.conversation.ConversationType; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.responses.AllIsWell; import com.fagi.responses.NoSuchUser; import com.fagi.responses.PasswordError; @@ -31,6 +33,7 @@ * Contains and update information on users. */ public class Data { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(Data.class); private final Map OUTPUT_AGENT_MAP = new ConcurrentHashMap<>(); private final Map INPUT_AGENT_MAP = new ConcurrentHashMap<>(); private final Map registeredUsers = new ConcurrentHashMap<>(); @@ -164,7 +167,7 @@ public void userLogout(String userName) { OUTPUT_AGENT_MAP.remove(userName); INPUT_AGENT_MAP.remove(userName); } else { - System.out.println("Couldn't log " + userName + " out"); + LOGGER.info(() -> "Couldn't log " + userName + " out"); } } diff --git a/fagiServer/src/main/java/com/fagi/server/Server.java b/fagiServer/src/main/java/com/fagi/server/Server.java index 9beda6d7..82a96a74 100644 --- a/fagiServer/src/main/java/com/fagi/server/Server.java +++ b/fagiServer/src/main/java/com/fagi/server/Server.java @@ -10,6 +10,8 @@ import com.fagi.encryption.Encryption; import com.fagi.encryption.RSAKey; import com.fagi.handler.ConversationHandler; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.model.Data; import com.fagi.model.InviteCodeContainer; import com.fagi.running.CheckFieldRunningStrategy; @@ -30,6 +32,7 @@ import java.util.List; public class Server { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(Server.class); static final String CONFIG_FILE = "config/serverinfo.config"; private final Data data; private IsRunningStrategy isRunningStrategy = new CheckFieldRunningStrategy(); @@ -64,12 +67,15 @@ public Server( data.storeInviteCodes(new InviteCodeContainer(new ArrayList<>())); } } catch (IOException e) { - e.printStackTrace(); + LOGGER.error( + e, + () -> "Could not create server." + ); } } public void start(ServerSocket serverSocket) { - System.out.println("Starting Server"); + LOGGER.info(() -> "Starting Server"); conversationHandlerThread = new Thread(handler); conversationHandlerThread.setDaemon(true); @@ -80,20 +86,26 @@ public void start(ServerSocket serverSocket) { try { workerCreation(serverSocket); } catch (IOException e) { - System.out.println("Error in server loop exception = " + e); + LOGGER.error( + e, + () -> "Error in server loop exception" + ); isRunningStrategy.stop(); } } conversationHandlerThread.interrupt(); - System.out.println("Stopping Server"); + LOGGER.info(() -> "Stopping Server"); if (serverSocket != null) { try { serverSocket.close(); } catch (IOException e) { - e.printStackTrace(); + LOGGER.error( + e, + () -> "Failed to close server socket gracefully." + ); } } } diff --git a/fagiServer/src/main/java/com/fagi/worker/InputWorker.java b/fagiServer/src/main/java/com/fagi/worker/InputWorker.java index e0eab39b..b45b2b4c 100644 --- a/fagiServer/src/main/java/com/fagi/worker/InputWorker.java +++ b/fagiServer/src/main/java/com/fagi/worker/InputWorker.java @@ -9,6 +9,8 @@ import com.fagi.encryption.EncryptionAlgorithm; import com.fagi.handler.ConversationHandler; import com.fagi.handler.InputHandler; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.model.Data; import java.io.EOFException; @@ -20,6 +22,7 @@ * @author miniwolf */ public class InputWorker extends Worker implements InputAgent { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(InputWorker.class); private final InputHandler inputHandler; private final Data data; private final ObjectInputStream objIn; @@ -35,9 +38,6 @@ public InputWorker( ConversationHandler handler, Data data) { this.data = data; - // TODO: This sysout does not make sense. Should be in the run method or where the thread is started. - // https://trello.com/c/SVazRIgj/58-inputworker-should-not-print-starting-an-input-thread-in-its-constructor - System.out.println("Starting an input thread"); this.objIn = objIn; this.out = out; this.inputHandler = new InputHandler( @@ -50,8 +50,9 @@ public InputWorker( @Override public void run() { + LOGGER.info(() -> "Starting an input thread"); while (isRunningStrategy.isRunning()) { - System.out.println("Running"); + LOGGER.info(() -> "Running"); try { Object input = objIn.readObject(); @@ -64,19 +65,21 @@ public void run() { inputHandler.handleInput(input); } catch (EOFException | SocketException eof) { stop(); - System.out.println("Logging out user " + myUserName); + LOGGER.info(() -> "Logging out user " + myUserName); out.stop(); data.userLogout(myUserName); } catch (Exception e) { stop(); out.stop(); - System.out.println("Something went wrong in a input worker while loop " + e); - e.printStackTrace(); - System.out.println("Logging out user " + myUserName); + LOGGER.error( + e, + () -> "Something went wrong in a input worker while loop." + ); + LOGGER.info(() -> "Logging out user " + myUserName); data.userLogout(myUserName); } } - System.out.println("Closing input"); + LOGGER.info(() -> "Closing input."); } private Object decryptAndConvertToObject(byte[] input) { @@ -89,7 +92,10 @@ private Object decryptAndConvertToObject(byte[] input) { try { return Conversion.convertFromBytes(input); } catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); + LOGGER.error( + e, + () -> "Failed to decrypt or deserialize object." + ); } return null; } diff --git a/fagiServer/src/main/java/com/fagi/worker/OutputWorker.java b/fagiServer/src/main/java/com/fagi/worker/OutputWorker.java index 5203f6e4..c30bbbcf 100644 --- a/fagiServer/src/main/java/com/fagi/worker/OutputWorker.java +++ b/fagiServer/src/main/java/com/fagi/worker/OutputWorker.java @@ -6,6 +6,8 @@ import com.fagi.encryption.AESKey; import com.fagi.encryption.Conversion; import com.fagi.encryption.EncryptionAlgorithm; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.fagi.model.Data; import com.fagi.model.FriendRequest; import com.fagi.model.User; @@ -26,6 +28,7 @@ * @author miniwolf */ public class OutputWorker extends Worker implements OutputAgent { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(OutputWorker.class); private final Queue messages = new ConcurrentLinkedQueue<>(); private final Queue respondObjects = new ConcurrentLinkedQueue<>(); private final Data data; @@ -45,7 +48,7 @@ public OutputWorker( @Override public void run() { while (isRunningStrategy.isRunning()) { - System.out.println("Running"); + LOGGER.info(() -> "Running"); try { sendIncMessages(); sendResponses(); @@ -58,8 +61,10 @@ public void run() { } } catch (IOException | InterruptedException ioe) { stop(); - System.out.println(ioe.toString()); - System.out.println("Logging out user " + myUserName); + LOGGER.error( + ioe, + () -> "Logging out user " + myUserName + ); data.userLogout(myUserName); } } @@ -69,7 +74,7 @@ public void run() { } catch (IOException ignored) { // User logged off we didn't manage to send response } } - System.out.println("Closing output"); + LOGGER.info(() -> "Closing output"); } private void checkForLists() throws IOException { @@ -103,7 +108,7 @@ private void sendIncMessages() throws IOException { } private void send(Object object) throws IOException { - System.out.println("so: " + object.toString()); + LOGGER.debug(() -> "Sending the following object to user " + myUserName + ": " + object.toString()); objOut.writeObject(aes.encrypt(Conversion.convertToBytes(object))); objOut.flush(); } diff --git a/fagiServer/src/test/java/TextMessageIntegrationTests.java b/fagiServer/src/test/java/TextMessageIntegrationTests.java index 9ace37d9..cd3b4665 100644 --- a/fagiServer/src/test/java/TextMessageIntegrationTests.java +++ b/fagiServer/src/test/java/TextMessageIntegrationTests.java @@ -1,3 +1,4 @@ +import com.fagi.BaseFagiTest; import com.fagi.conversation.Conversation; import com.fagi.conversation.ConversationType; import com.fagi.handler.ConversationHandler; @@ -17,7 +18,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; -public class TextMessageIntegrationTests { +public class TextMessageIntegrationTests extends BaseFagiTest { private OutputAgent outputAgent; private ConversationHandler conversationHandler; private Data data; diff --git a/fagiServer/src/test/java/com/fagi/encryption/EncryptionTests.java b/fagiServer/src/test/java/com/fagi/encryption/EncryptionTests.java index e8e2b97a..3228cd10 100644 --- a/fagiServer/src/test/java/com/fagi/encryption/EncryptionTests.java +++ b/fagiServer/src/test/java/com/fagi/encryption/EncryptionTests.java @@ -1,16 +1,19 @@ package com.fagi.encryption; +import com.fagi.BaseFagiTest; +import com.fagi.logging.TestLogLevel; +import com.fagi.logging.TestLogRecord; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; -import java.io.PrintStream; import java.security.KeyPair; import java.security.NoSuchAlgorithmException; import java.security.spec.InvalidKeySpecException; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; @@ -18,12 +21,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -class EncryptionTests { +class EncryptionTests extends BaseFagiTest { private static final String PUBLIC_KEY_PATH = "build/test/data/encryption_test/public.key"; @AfterEach void tearDown() { - System.setErr(System.err); deleteFileAndFolder(PUBLIC_KEY_PATH); deleteFileAndFolder(KeyStorage.PUBLICKEYFILE); deleteFileAndFolder(KeyStorage.PRIVATEKEYFILE); @@ -39,13 +41,33 @@ public void whenLoadKeyPairThrowsIOException_ThenConstructorPrintsStacktrace() t createPublicKeyFile(); - var outContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(outContent)); - new Encryption(PUBLIC_KEY_PATH); - String consoleOutput = outContent.toString(); - assertTrue(consoleOutput.contains("java.io.IOException")); + List> logRecords = lookupLogRecordsForClass(Encryption.class); + Assertions.assertAll( + () -> Assertions.assertEquals( + 1, + logRecords.size() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + logRecords + .getFirst() + .logLevel() + ), + () -> Assertions.assertEquals( + "Could not load RSA key pair.", + logRecords + .getFirst() + .message() + ), + () -> Assertions.assertInstanceOf( + IOException.class, + logRecords + .getFirst() + .throwable() + ) + ); } } @@ -58,13 +80,33 @@ public void whenLoadKeyPairThrowsNoSuchAlgorithmException_ThenConstructorPrintsS createPublicKeyFile(); - var outContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(outContent)); - new Encryption(PUBLIC_KEY_PATH); - String consoleOutput = outContent.toString(); - assertTrue(consoleOutput.contains("java.security.NoSuchAlgorithmException")); + List> logRecords = lookupLogRecordsForClass(Encryption.class); + Assertions.assertAll( + () -> Assertions.assertEquals( + 1, + logRecords.size() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + logRecords + .getFirst() + .logLevel() + ), + () -> Assertions.assertEquals( + "Could not load RSA key pair.", + logRecords + .getFirst() + .message() + ), + () -> Assertions.assertInstanceOf( + NoSuchAlgorithmException.class, + logRecords + .getFirst() + .throwable() + ) + ); } } @@ -77,13 +119,33 @@ public void whenLoadKeyPairThrowsInvalidKeySpecException_ThenConstructorPrintsSt createPublicKeyFile(); - var outContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(outContent)); - new Encryption(PUBLIC_KEY_PATH); - String consoleOutput = outContent.toString(); - assertTrue(consoleOutput.contains("java.security.spec.InvalidKeySpecException")); + List> logRecords = lookupLogRecordsForClass(Encryption.class); + Assertions.assertAll( + () -> Assertions.assertEquals( + 1, + logRecords.size() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + logRecords + .getFirst() + .logLevel() + ), + () -> Assertions.assertEquals( + "Could not load RSA key pair.", + logRecords + .getFirst() + .message() + ), + () -> Assertions.assertInstanceOf( + InvalidKeySpecException.class, + logRecords + .getFirst() + .throwable() + ) + ); } } @@ -94,13 +156,33 @@ void whenSaveKeyPairThrowsIOException_ThenConstructorPrintsStacktrace() { .when(() -> KeyStorage.SaveKeyPair(any())) .thenThrow(new IOException()); - var outContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(outContent)); - new Encryption(PUBLIC_KEY_PATH); - String consoleOutput = outContent.toString(); - assertTrue(consoleOutput.contains("java.io.IOException")); + List> logRecords = lookupLogRecordsForClass(Encryption.class); + Assertions.assertAll( + () -> Assertions.assertEquals( + 1, + logRecords.size() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + logRecords + .getFirst() + .logLevel() + ), + () -> Assertions.assertEquals( + "Could not save RSA key pair.", + logRecords + .getFirst() + .message() + ), + () -> Assertions.assertInstanceOf( + IOException.class, + logRecords + .getFirst() + .throwable() + ) + ); } } diff --git a/fagiServer/src/test/java/com/fagi/handler/ConversationHandlerTest.java b/fagiServer/src/test/java/com/fagi/handler/ConversationHandlerTest.java index b17150f7..6e1bd77b 100644 --- a/fagiServer/src/test/java/com/fagi/handler/ConversationHandlerTest.java +++ b/fagiServer/src/test/java/com/fagi/handler/ConversationHandlerTest.java @@ -1,7 +1,10 @@ package com.fagi.handler; +import com.fagi.BaseFagiTest; import com.fagi.conversation.Conversation; import com.fagi.conversation.ConversationType; +import com.fagi.logging.TestLogLevel; +import com.fagi.logging.TestLogRecord; import com.fagi.model.Data; import com.fagi.model.User; import com.fagi.model.messages.message.TextMessage; @@ -10,13 +13,14 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; +import java.util.List; +import java.util.concurrent.TimeUnit; -class ConversationHandlerTest { +class ConversationHandlerTest extends BaseFagiTest { private Data data; private ConversationHandler conversationHandler; @@ -195,12 +199,8 @@ void givenMessageInQueue_WhenTwoOfThreeParticipantsAreOnline_ThenOnlyTheOnlinePa } @Test - void dummy() throws InterruptedException { - var outContent = new ByteArrayOutputStream(); - var errorContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(errorContent)); - System.setOut(new PrintStream(outContent)); - + @Timeout(value = 2, unit = TimeUnit.SECONDS) + void testConversationHandlerLogsMessageWhenInterrupted() throws InterruptedException { var thread = new Thread(conversationHandler); thread.setDaemon(true); @@ -210,13 +210,34 @@ void dummy() throws InterruptedException { thread.interrupt(); + List> logRecords = lookupLogRecordsForClass(ConversationHandler.class); + + while (logRecords.isEmpty()) { + Thread.sleep(100); + logRecords = lookupLogRecordsForClass(ConversationHandler.class); + } + + Assertions.assertEquals( + 1, + logRecords.size() + ); + + TestLogRecord logRecord = logRecords.getFirst(); + Assertions.assertAll( - () -> Assertions.assertTrue(errorContent.toString().contains("java.lang.InterruptedException")), - () -> Assertions.assertFalse(outContent.toString().contains("java.lang.InterruptedException")), + () -> Assertions.assertEquals( + "Interrupted ConversationHandler thread.", + logRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.DEBUG, + logRecord.logLevel() + ), + () -> Assertions.assertInstanceOf( + InterruptedException.class, + logRecord.throwable() + ), () -> Assertions.assertTrue(thread.isInterrupted()) ); - - System.setErr(System.err); - System.setOut(System.out); } } \ No newline at end of file diff --git a/fagiServer/src/test/java/com/fagi/handler/inputhandler/BaseInputHandlerTest.java b/fagiServer/src/test/java/com/fagi/handler/inputhandler/BaseInputHandlerTest.java index ce56a491..e519c722 100644 --- a/fagiServer/src/test/java/com/fagi/handler/inputhandler/BaseInputHandlerTest.java +++ b/fagiServer/src/test/java/com/fagi/handler/inputhandler/BaseInputHandlerTest.java @@ -1,5 +1,6 @@ package com.fagi.handler.inputhandler; +import com.fagi.BaseFagiTest; import com.fagi.conversation.Conversation; import com.fagi.conversation.ConversationType; import com.fagi.handler.ConversationHandler; @@ -23,7 +24,7 @@ * A base class to make it easy for all {@link InputHandler} tests to have the base set up ready for each test. * Also contains helpful methods to simplify st ups and mocks that are used multiple times. */ -public abstract class BaseInputHandlerTest { +public abstract class BaseInputHandlerTest extends BaseFagiTest { protected OutputAgent outputAgent; protected Data data; protected InputAgent inputAgent; diff --git a/fagiServer/src/test/java/com/fagi/handler/inputhandler/NotValidRequestTests.java b/fagiServer/src/test/java/com/fagi/handler/inputhandler/NotValidRequestTests.java index 3dfc5be8..c3feb210 100644 --- a/fagiServer/src/test/java/com/fagi/handler/inputhandler/NotValidRequestTests.java +++ b/fagiServer/src/test/java/com/fagi/handler/inputhandler/NotValidRequestTests.java @@ -1,28 +1,20 @@ package com.fagi.handler.inputhandler; +import com.fagi.handler.InputHandler; +import com.fagi.logging.TestLogLevel; +import com.fagi.logging.TestLogRecord; import com.fagi.util.OutputAgentTestUtil; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; +import java.util.List; import static org.mockito.Mockito.when; public class NotValidRequestTests extends BaseInputHandlerTest { - private final PrintStream standardOut = System.out; - private final ByteArrayOutputStream outputStreamCaptor = new ByteArrayOutputStream(); - void beforeEach() { when(data.getOutputAgent(Mockito.anyString())).thenReturn(outputAgent); - System.setOut(new PrintStream(outputStreamCaptor)); - } - - @AfterEach - public void tearDown() { - System.setOut(standardOut); } @Test @@ -36,14 +28,27 @@ void whenSendingUnknownRequestObject_ShouldGiveNoResponse() { } @Test - void whenSendingUnknownRequestObject_ShouldPrintMessageInSysOut() { + void whenSendingUnknownRequestObject_ShouldLogMessage() { inputHandler.handleInput(new UnknownRequest()); + List> testLogRecords = lookupLogRecordsForClass(InputHandler.class); + Assertions.assertEquals( - "Unknown handle: " + UnknownRequest.class, - outputStreamCaptor - .toString() - .trim() + 1, + testLogRecords.size() + ); + + TestLogRecord logRecord = testLogRecords.getFirst(); + + Assertions.assertAll( + () -> Assertions.assertEquals( + "Unknown handle: " + UnknownRequest.class, + logRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.INFO, + logRecord.logLevel() + ) ); } @@ -58,14 +63,27 @@ void givenInputIsNull_ShouldGiveNoResponse() { } @Test - void givenInputIsNull_ShouldPrintMessageInSysOut() { + void givenInputIsNull_ShouldLogMessage() { inputHandler.handleInput(null); + List> testLogRecords = lookupLogRecordsForClass(InputHandler.class); + Assertions.assertEquals( - "Input is null. Doing nothing.", - outputStreamCaptor - .toString() - .trim() + 1, + testLogRecords.size() + ); + + TestLogRecord logRecord = testLogRecords.getFirst(); + + Assertions.assertAll( + () -> Assertions.assertEquals( + "Input is null. Doing nothing.", + logRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.INFO, + logRecord.logLevel() + ) ); } diff --git a/fagiServer/src/test/java/com/fagi/model/DataTests.java b/fagiServer/src/test/java/com/fagi/model/DataTests.java index 39189da7..c899619f 100644 --- a/fagiServer/src/test/java/com/fagi/model/DataTests.java +++ b/fagiServer/src/test/java/com/fagi/model/DataTests.java @@ -1,7 +1,10 @@ package com.fagi.model; +import com.fagi.BaseFagiTest; import com.fagi.conversation.Conversation; import com.fagi.conversation.ConversationType; +import com.fagi.logging.TestLogLevel; +import com.fagi.logging.TestLogRecord; import com.fagi.responses.AllIsWell; import com.fagi.responses.NoSuchUser; import com.fagi.responses.PasswordError; @@ -11,21 +14,18 @@ import com.fagi.utility.JsonFileOperations; import com.fagi.worker.InputAgent; import com.fagi.worker.OutputAgent; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; import java.util.List; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; -class DataTests { +class DataTests extends BaseFagiTest { private Data data; private User user; private InputAgent inputAgent; @@ -42,11 +42,6 @@ void setup() { ); } - @AfterEach - void tearDown() { - System.setOut(System.out); - } - @Test void userAlreadyOnline_ShouldResultInUserOnlineResponse() { doReturn(true) @@ -529,10 +524,7 @@ void storeInviteCodes_ShouldAttemptToStoreInviteCodesToDisk() { } @Test - void logoutUserWithNotLoggedInUser_ShouldResultInPrintToSysOut() { - var outputStreamCaptor = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outputStreamCaptor)); - + void logoutUserWithNotLoggedInUser_ShouldLogMessage() { var user = new User( "bob", "password" @@ -540,11 +532,24 @@ void logoutUserWithNotLoggedInUser_ShouldResultInPrintToSysOut() { data.userLogout(user.getUserName()); + List> testLogRecords = lookupLogRecordsForClass(Data.class); + Assertions.assertEquals( - "Couldn't log " + user.getUserName() + " out", - outputStreamCaptor - .toString() - .trim() + 1, + testLogRecords.size() + ); + + TestLogRecord logRecord = testLogRecords.getFirst(); + + Assertions.assertAll( + () -> Assertions.assertEquals( + "Couldn't log " + user.getUserName() + " out", + logRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.INFO, + logRecord.logLevel() + ) ); } diff --git a/fagiServer/src/test/java/com/fagi/model/UserTests.java b/fagiServer/src/test/java/com/fagi/model/UserTests.java index 303e70b4..c0bcdc25 100644 --- a/fagiServer/src/test/java/com/fagi/model/UserTests.java +++ b/fagiServer/src/test/java/com/fagi/model/UserTests.java @@ -1,5 +1,6 @@ package com.fagi.model; +import com.fagi.BaseFagiTest; import com.fagi.model.messages.message.TextMessage; import com.fagi.responses.AllIsWell; import com.fagi.responses.NoSuchUser; @@ -23,7 +24,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -public class UserTests { +public class UserTests extends BaseFagiTest { private Data data; private User user; private User secondUser; diff --git a/fagiServer/src/test/java/com/fagi/server/ServerConversationHandlerTests.java b/fagiServer/src/test/java/com/fagi/server/ServerConversationHandlerTests.java index 69cd97af..4ecbba91 100644 --- a/fagiServer/src/test/java/com/fagi/server/ServerConversationHandlerTests.java +++ b/fagiServer/src/test/java/com/fagi/server/ServerConversationHandlerTests.java @@ -6,7 +6,7 @@ import com.fagi.model.messages.message.TextMessage; import com.fagi.running.IsRunningStrategy; import com.fagi.util.DataTestUtil; -import com.fagi.util.NeverRunStrategy; +import com.fagi.util.running.NeverRunStrategy; import com.fagi.utility.JsonFileOperations; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; diff --git a/fagiServer/src/test/java/com/fagi/server/ServerLoggingTests.java b/fagiServer/src/test/java/com/fagi/server/ServerLoggingTests.java index 89733789..c582d21a 100644 --- a/fagiServer/src/test/java/com/fagi/server/ServerLoggingTests.java +++ b/fagiServer/src/test/java/com/fagi/server/ServerLoggingTests.java @@ -1,30 +1,21 @@ package com.fagi.server; -import com.fagi.util.NeverRunStrategy; -import org.junit.jupiter.api.AfterEach; +import com.fagi.logging.TestLogLevel; +import com.fagi.logging.TestLogRecord; +import com.fagi.util.running.NeverRunStrategy; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.PrintStream; import java.net.ServerSocket; import java.nio.file.Files; +import java.util.List; class ServerLoggingTests extends ServerTests { - @AfterEach - void tearDown() { - System.setOut(System.out); - System.setErr(System.err); - } - @Test - void givenFileWriteGivesIOException_ThenShouldPrintErrorToErrorConsole() { + void givenFileWriteGivesIOException_ThenShouldLogError() { try (var mockedFiles = Mockito.mockStatic(Files.class)) { - var outContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(outContent)); - mockedFiles .when(() -> Files.write( Mockito.any(), @@ -38,18 +29,38 @@ void givenFileWriteGivesIOException_ThenShouldPrintErrorToErrorConsole() { data ); - String consoleOutput = outContent.toString(); - Assertions.assertTrue(consoleOutput.contains("java.io.IOException")); + List> testLogRecords = lookupLogRecordsForClass( + Server.class, + TestLogLevel.ERROR + ); + + Assertions.assertEquals( + 1, + testLogRecords.size() + ); + + TestLogRecord logRecord = testLogRecords.getFirst(); + + Assertions.assertAll( + () -> Assertions.assertEquals( + "Could not create server.", + logRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + logRecord.logLevel() + ), + () -> Assertions.assertInstanceOf( + IOException.class, + logRecord.throwable() + ) + ); } } @Test - void givenServerSocketAcceptThrowsIOException_ThenShouldPrintErrorToSysOutButNotSysError() throws IOException { + void givenServerSocketAcceptThrowsIOException_ThenShouldLogError() throws IOException { var serverSocket = Mockito.mock(ServerSocket.class); - var outContent = new ByteArrayOutputStream(); - var outErrorContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - System.setErr(new PrintStream(outErrorContent)); Mockito .when(serverSocket.accept()) @@ -61,30 +72,38 @@ void givenServerSocketAcceptThrowsIOException_ThenShouldPrintErrorToSysOutButNot ); server.start(serverSocket); + List> testLogRecords = lookupLogRecordsForClass( + Server.class, + TestLogLevel.ERROR + ); + + Assertions.assertEquals( + 1, + testLogRecords.size() + ); + + TestLogRecord logRecord = testLogRecords.getFirst(); + Assertions.assertAll( - () -> Assertions.assertTrue(outContent - .toString() - .contains("Error in server loop exception = ")), - () -> Assertions.assertTrue(outContent - .toString() - .contains("java.io.IOException")), - () -> Assertions.assertFalse(outErrorContent - .toString() - .contains("Error in server loop exception = ")), - () -> Assertions.assertFalse(outErrorContent - .toString() - .contains("java.io.IOException")), + () -> Assertions.assertEquals( + "Error in server loop exception", + logRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + logRecord.logLevel() + ), + () -> Assertions.assertInstanceOf( + IOException.class, + logRecord.throwable() + ), () -> Assertions.assertFalse(server.isRunning()) ); } @Test - void givenServerSocketCloseThrowsIOException_ThenShouldPrintErrorToSysErrorButNotSysOut() throws IOException { + void givenServerSocketCloseThrowsIOException_ThenShouldBeLogged() throws IOException { var serverSocket = Mockito.mock(ServerSocket.class); - var outContent = new ByteArrayOutputStream(); - var outErrorContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - System.setErr(new PrintStream(outErrorContent)); Mockito .doThrow(new IOException()) @@ -98,22 +117,36 @@ void givenServerSocketCloseThrowsIOException_ThenShouldPrintErrorToSysErrorButNo server.setIsRunningStrategy(new NeverRunStrategy()); server.start(serverSocket); + List> testLogRecords = lookupLogRecordsForClass( + Server.class, + TestLogLevel.ERROR + ); + + Assertions.assertEquals( + 1, + testLogRecords.size() + ); + + TestLogRecord logRecord = testLogRecords.getFirst(); + Assertions.assertAll( - () -> Assertions.assertFalse(outContent - .toString() - .contains("java.io.IOException")), - () -> Assertions.assertTrue(outErrorContent - .toString() - .contains("java.io.IOException")), - () -> Assertions.assertFalse(server.isRunning()) + () -> Assertions.assertEquals( + "Failed to close server socket gracefully.", + logRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + logRecord.logLevel() + ), + () -> Assertions.assertInstanceOf( + IOException.class, + logRecord.throwable() + ) ); } @Test - void whenServerStarts_ThenStartingServerIsPrintedInSysOut() { - var outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - + void whenServerStartsAndThenStops_ThenShouldLogThatServerStartsAndStops() { var server = new Server( serverPort, data @@ -121,25 +154,42 @@ void whenServerStarts_ThenStartingServerIsPrintedInSysOut() { server.setIsRunningStrategy(new NeverRunStrategy()); server.start(null); - Assertions.assertTrue(outContent - .toString() - .contains("Starting Server")); - } - - @Test - void whenServerStops_ThenStoppingServerIsPrintedInSysOut() { - var outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); + List> testLogRecords = lookupLogRecordsForClass( + Server.class, + TestLogLevel.INFO + ); - var server = new Server( - serverPort, - data + Assertions.assertEquals( + 2, + testLogRecords.size() ); - server.setIsRunningStrategy(new NeverRunStrategy()); - server.start(null); - Assertions.assertTrue(outContent - .toString() - .contains("Stopping Server")); + Assertions.assertAll( + () -> Assertions.assertEquals( + "Starting Server", + testLogRecords + .getFirst() + .message() + ), + () -> Assertions.assertEquals( + TestLogLevel.INFO, + testLogRecords + .getFirst() + .logLevel() + ), + + () -> Assertions.assertEquals( + "Stopping Server", + testLogRecords + .getLast() + .message() + ), + () -> Assertions.assertEquals( + TestLogLevel.INFO, + testLogRecords + .getLast() + .logLevel() + ) + ); } } diff --git a/fagiServer/src/test/java/com/fagi/server/ServerTests.java b/fagiServer/src/test/java/com/fagi/server/ServerTests.java index 264f5210..9fdb8df9 100644 --- a/fagiServer/src/test/java/com/fagi/server/ServerTests.java +++ b/fagiServer/src/test/java/com/fagi/server/ServerTests.java @@ -1,5 +1,6 @@ package com.fagi.server; +import com.fagi.BaseFagiTest; import com.fagi.model.Data; import com.fagi.utility.JsonFileOperations; import org.junit.jupiter.api.AfterEach; @@ -8,7 +9,7 @@ import java.io.File; -abstract class ServerTests { +abstract class ServerTests extends BaseFagiTest { protected final int serverPort = 4242; protected Data data; diff --git a/fagiServer/src/test/java/com/fagi/server/ServerWorkerTests.java b/fagiServer/src/test/java/com/fagi/server/ServerWorkerTests.java index ced9ef77..be24bd2c 100644 --- a/fagiServer/src/test/java/com/fagi/server/ServerWorkerTests.java +++ b/fagiServer/src/test/java/com/fagi/server/ServerWorkerTests.java @@ -12,7 +12,7 @@ import com.fagi.responses.AllIsWell; import com.fagi.running.IsRunningStrategy; import com.fagi.util.DataTestUtil; -import com.fagi.util.RunOnceStrategy; +import com.fagi.util.running.RunOnceStrategy; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; diff --git a/fagiServer/src/test/java/com/fagi/util/NeverRunStrategy.java b/fagiServer/src/test/java/com/fagi/util/running/NeverRunStrategy.java similarity index 90% rename from fagiServer/src/test/java/com/fagi/util/NeverRunStrategy.java rename to fagiServer/src/test/java/com/fagi/util/running/NeverRunStrategy.java index d1ca6019..f31748b1 100644 --- a/fagiServer/src/test/java/com/fagi/util/NeverRunStrategy.java +++ b/fagiServer/src/test/java/com/fagi/util/running/NeverRunStrategy.java @@ -1,4 +1,4 @@ -package com.fagi.util; +package com.fagi.util.running; import com.fagi.running.IsRunningStrategy; diff --git a/fagiServer/src/test/java/com/fagi/util/RunOnceStrategy.java b/fagiServer/src/test/java/com/fagi/util/running/RunOnceStrategy.java similarity index 94% rename from fagiServer/src/test/java/com/fagi/util/RunOnceStrategy.java rename to fagiServer/src/test/java/com/fagi/util/running/RunOnceStrategy.java index 0640b3c0..2cd7b0c5 100644 --- a/fagiServer/src/test/java/com/fagi/util/RunOnceStrategy.java +++ b/fagiServer/src/test/java/com/fagi/util/running/RunOnceStrategy.java @@ -1,4 +1,4 @@ -package com.fagi.util; +package com.fagi.util.running; import com.fagi.running.IsRunningStrategy; diff --git a/fagiServer/src/test/java/com/fagi/worker/InputWorkerTests.java b/fagiServer/src/test/java/com/fagi/worker/InputWorkerTests.java index c5b3d1b0..37678c37 100644 --- a/fagiServer/src/test/java/com/fagi/worker/InputWorkerTests.java +++ b/fagiServer/src/test/java/com/fagi/worker/InputWorkerTests.java @@ -1,5 +1,6 @@ package com.fagi.worker; +import com.fagi.BaseFagiTest; import com.fagi.encryption.AES; import com.fagi.encryption.AESKey; import com.fagi.encryption.Conversion; @@ -7,36 +8,34 @@ import com.fagi.encryption.RSA; import com.fagi.handler.ConversationHandler; import com.fagi.handler.InputHandler; +import com.fagi.logging.TestLogLevel; +import com.fagi.logging.TestLogRecord; import com.fagi.model.Data; import com.fagi.model.Login; import com.fagi.model.Session; import com.fagi.model.UserNameAvailableRequest; import com.fagi.responses.AllIsWell; -import com.fagi.util.NeverRunStrategy; import com.fagi.util.OutputAgentTestUtil; -import com.fagi.util.RunOnceStrategy; -import org.junit.jupiter.api.AfterEach; +import com.fagi.util.running.NeverRunStrategy; +import com.fagi.util.running.RunOnceStrategy; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import java.io.ByteArrayOutputStream; import java.io.EOFException; import java.io.IOException; import java.io.ObjectInputStream; -import java.io.PrintStream; import java.net.SocketException; import java.net.SocketTimeoutException; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; -public class InputWorkerTests { +public class InputWorkerTests extends BaseFagiTest { private static final byte[] ENCRYPTED_DATA = "encryptedData".getBytes(); private OutputWorker outputWorker; private ConversationHandler conversationHandler; @@ -61,26 +60,19 @@ void setup() throws IOException, ClassNotFoundException { ); } - @AfterEach - void tearDown() { - System.setErr(System.err); - System.setOut(System.out); - } - @Test - void whenInputWorkerIsCreated_ThenStartingThreadMessagesIsPrintedToConsole() { - var outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); + void whenInputWorkerIsStarted_ThenStartingThreadMessageIsLogged() { + inputWorker.setIsRunningStrategy(new NeverRunStrategy()); - new InputWorker( - mockObjectInputStream, - outputWorker, - conversationHandler, - data - ); + inputWorker.run(); + + List> testLogRecords = lookupLogRecordsForClass(InputWorker.class); - String consoleOutput = outContent.toString(); - assertTrue(consoleOutput.contains("Starting an input thread")); + Assertions.assertTrue(testLogRecords + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals("Starting an input thread"))); } @Test @@ -109,33 +101,37 @@ void givenUsernameSetToCharles_ThenUsernameShouldBeCharles() { } @Test - void givenRunningIsSetToFalse_WhenWorkerIsRunning_ThenShouldNotPrintRunningToConsole() { - var outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - + void givenRunningIsSetToFalse_WhenWorkerIsRunning_ThenShouldNotLogRunning() { inputWorker.setIsRunningStrategy(new NeverRunStrategy()); inputWorker.run(); - String consoleOutput = outContent.toString(); - assertFalse(consoleOutput.contains("Running")); + List> testLogRecords = lookupLogRecordsForClass(InputWorker.class); + + Assertions.assertFalse(testLogRecords + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals("Running"))); } @Test - void givenRunningIsSetToFalse_WhenWorkerIsRunning_ThenShouldPrintClosingInputToConsole() { - var outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - + void givenRunningIsSetToFalse_WhenWorkerIsRunning_ThenShouldLogClosingInput() { inputWorker.setIsRunningStrategy(new NeverRunStrategy()); inputWorker.run(); - String consoleOutput = outContent.toString(); - assertTrue(consoleOutput.contains("Closing input")); + List> testLogRecords = lookupLogRecordsForClass(InputWorker.class); + + Assertions.assertTrue(testLogRecords + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals("Closing input."))); } @Test - void givenRunningIsSetToTrue_WhenWorkerIsRunning_ThenShouldPrintRunningToConsole() { + void givenRunningIsSetToTrue_WhenWorkerIsRunning_ThenShouldLogRunning() { try (var mockedConversion = Mockito.mockStatic(Conversion.class)) { mockedConversion .when(() -> Conversion.convertFromBytes(any())) @@ -144,17 +140,20 @@ void givenRunningIsSetToTrue_WhenWorkerIsRunning_ThenShouldPrintRunningToConsole var mockAes = Mockito.mock(AES.class); when(mockAes.decrypt(any())).thenReturn("decrypted".getBytes()); - var outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - inputWorker.setIsRunningStrategy(new RunOnceStrategy()); inputWorker.setSessionCreated(true); inputWorker.setAes(mockAes); inputWorker.run(); - String consoleOutput = outContent.toString(); - assertTrue(consoleOutput.contains("Running")); + + List> testLogRecords = lookupLogRecordsForClass(InputWorker.class); + + Assertions.assertTrue(testLogRecords + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals("Running"))); } } @@ -235,7 +234,7 @@ void givenSessionCreated_WhenWorkerReceivesEncryptedObject_TheAESDecryptionIsCal } @Test - void givenConversionFailsToConvertByteArrayToObject_WhenWorkerReceivesEncryptedObject_ThenSystemErrorShouldContainClassNotFoundException() { + void givenConversionFailsToConvertByteArrayToObject_WhenWorkerReceivesEncryptedObject_ThenShouldLogError() { try (var mockedConversion = Mockito.mockStatic(Conversion.class)) { byte[] decryptedInput = "decryptedData".getBytes(); var mockedAES = Mockito.mock(AES.class); @@ -245,23 +244,43 @@ void givenConversionFailsToConvertByteArrayToObject_WhenWorkerReceivesEncryptedO .when(() -> Conversion.convertFromBytes(any())) .thenThrow(new ClassNotFoundException()); - - var outContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(outContent)); - inputWorker.setIsRunningStrategy(new RunOnceStrategy()); inputWorker.setSessionCreated(true); inputWorker.setAes(mockedAES); inputWorker.run(); - String consoleOutput = outContent.toString(); - assertTrue(consoleOutput.contains("java.lang.ClassNotFoundException")); + List> testLogRecords = lookupLogRecordsForClass( + InputWorker.class, + TestLogLevel.ERROR + ); + + Assertions.assertEquals( + 1, + testLogRecords.size() + ); + + TestLogRecord logRecord = testLogRecords.getFirst(); + + Assertions.assertAll( + () -> Assertions.assertEquals( + "Failed to decrypt or deserialize object.", + logRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + logRecord.logLevel() + ), + () -> Assertions.assertInstanceOf( + ClassNotFoundException.class, + logRecord.throwable() + ) + ); } } @Test - void givenConversionFailsWithIo_WhenWorkerReceivesEncryptedObject_ThenSystemErrorShouldContainIOException() { + void givenConversionFailsWithIo_WhenWorkerReceivesEncryptedObject_ThenShouldLogError() { try (var mockedConversion = Mockito.mockStatic(Conversion.class)) { byte[] decryptedInput = "decryptedData".getBytes(); var mockedAES = Mockito.mock(AES.class); @@ -271,18 +290,38 @@ void givenConversionFailsWithIo_WhenWorkerReceivesEncryptedObject_ThenSystemErro .when(() -> Conversion.convertFromBytes(any())) .thenThrow(new IOException()); - - var outContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(outContent)); - inputWorker.setIsRunningStrategy(new RunOnceStrategy()); inputWorker.setSessionCreated(true); inputWorker.setAes(mockedAES); inputWorker.run(); - String consoleOutput = outContent.toString(); - assertTrue(consoleOutput.contains("java.io.IOException")); + List> testLogRecords = lookupLogRecordsForClass( + InputWorker.class, + TestLogLevel.ERROR + ); + + Assertions.assertEquals( + 1, + testLogRecords.size() + ); + + TestLogRecord logRecord = testLogRecords.getFirst(); + + Assertions.assertAll( + () -> Assertions.assertEquals( + "Failed to decrypt or deserialize object.", + logRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + logRecord.logLevel() + ), + () -> Assertions.assertInstanceOf( + IOException.class, + logRecord.throwable() + ) + ); } } @@ -291,11 +330,6 @@ void givenSocketException_WhenRunning_ThenShouldHandleUserLogoutGracefully() thr var username = "my username"; when(mockObjectInputStream.readObject()).thenThrow(new SocketException()); - var outContent = new ByteArrayOutputStream(); - var errorContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(errorContent)); - System.setOut(new PrintStream(outContent)); - inputWorker.setSessionCreated(true); inputWorker.setUsername(username); @@ -315,15 +349,13 @@ void givenSocketException_WhenRunning_ThenShouldHandleUserLogoutGracefully() thr ) .stop(); - Assertions.assertAll( - () -> Assertions.assertFalse(inputWorker.isRunning()), - () -> Assertions.assertTrue(outContent - .toString() - .contains("Logging out user " + username)), - () -> Assertions.assertFalse(errorContent - .toString() - .contains("java.net.SocketException")) - ); + List> testLogRecords = lookupLogRecordsForClass(InputWorker.class); + + Assertions.assertTrue(testLogRecords + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals("Logging out user " + username))); } @Test @@ -331,11 +363,6 @@ void givenEOFException_WhenRunning_ThenShouldHandleUserLogoutGracefully() throws var username = "my username"; when(mockObjectInputStream.readObject()).thenThrow(new EOFException()); - var outContent = new ByteArrayOutputStream(); - var errorContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(errorContent)); - System.setOut(new PrintStream(outContent)); - inputWorker.setSessionCreated(true); inputWorker.setUsername(username); @@ -355,27 +382,20 @@ void givenEOFException_WhenRunning_ThenShouldHandleUserLogoutGracefully() throws ) .stop(); - Assertions.assertAll( - () -> Assertions.assertFalse(inputWorker.isRunning()), - () -> Assertions.assertTrue(outContent - .toString() - .contains("Logging out user " + username)), - () -> Assertions.assertFalse(errorContent - .toString() - .contains("java.io.EOFException")) - ); + List> testLogRecords = lookupLogRecordsForClass(InputWorker.class); + + Assertions.assertTrue(testLogRecords + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals("Logging out user " + username))); } @Test - void givenUnexpectedException_WhenRunning_ThenShouldHandleUserLogoutGracefullyButWithStacktrace() throws IOException, ClassNotFoundException { + void givenUnexpectedException_WhenRunning_ThenShouldHandleUserLogoutGracefullyButWithErrorLog() throws IOException, ClassNotFoundException { var username = "my username"; when(mockObjectInputStream.readObject()).thenThrow(new SocketTimeoutException()); - var outContent = new ByteArrayOutputStream(); - var errorContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(errorContent)); - System.setOut(new PrintStream(outContent)); - inputWorker.setSessionCreated(true); inputWorker.setUsername(username); @@ -395,20 +415,38 @@ void givenUnexpectedException_WhenRunning_ThenShouldHandleUserLogoutGracefullyBu ) .stop(); + Assertions.assertTrue(lookupLogRecordsForClass(InputWorker.class) + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals("Logging out user " + username))); + + + List> testErrorLogRecords = lookupLogRecordsForClass( + InputWorker.class, + TestLogLevel.ERROR + ); + + Assertions.assertEquals( + 1, + testErrorLogRecords.size() + ); + + TestLogRecord errorLogRecord = testErrorLogRecords.getFirst(); + Assertions.assertAll( - () -> Assertions.assertFalse(inputWorker.isRunning()), - () -> Assertions.assertTrue(outContent - .toString() - .contains("Logging out user " + username)), - () -> Assertions.assertTrue(outContent - .toString() - .contains("Something went wrong in a input worker while loop ")), - () -> Assertions.assertTrue(outContent - .toString() - .contains("java.net.SocketTimeoutException")), - () -> Assertions.assertTrue(errorContent - .toString() - .contains("java.net.SocketTimeoutException")) + () -> Assertions.assertEquals( + "Something went wrong in a input worker while loop.", + errorLogRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + errorLogRecord.logLevel() + ), + () -> Assertions.assertInstanceOf( + SocketTimeoutException.class, + errorLogRecord.throwable() + ) ); } diff --git a/fagiServer/src/test/java/com/fagi/worker/OutputWorkerTest.java b/fagiServer/src/test/java/com/fagi/worker/OutputWorkerTest.java index d9e34c8b..66dffd3f 100644 --- a/fagiServer/src/test/java/com/fagi/worker/OutputWorkerTest.java +++ b/fagiServer/src/test/java/com/fagi/worker/OutputWorkerTest.java @@ -1,7 +1,10 @@ package com.fagi.worker; +import com.fagi.BaseFagiTest; import com.fagi.encryption.AES; import com.fagi.encryption.Conversion; +import com.fagi.logging.TestLogLevel; +import com.fagi.logging.TestLogRecord; import com.fagi.model.Data; import com.fagi.model.FriendRequest; import com.fagi.model.User; @@ -12,9 +15,8 @@ import com.fagi.model.messages.message.TextMessage; import com.fagi.responses.AllIsWell; import com.fagi.responses.UserOnline; -import com.fagi.util.NeverRunStrategy; -import com.fagi.util.RunOnceStrategy; -import org.junit.jupiter.api.AfterEach; +import com.fagi.util.running.NeverRunStrategy; +import com.fagi.util.running.RunOnceStrategy; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -36,8 +38,9 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; -class OutputWorkerTest { +class OutputWorkerTest extends BaseFagiTest { private final ArgumentCaptor captor = ArgumentCaptor.forClass(byte[].class); + private final String username = "Charles"; private ObjectOutputStream objOut; private Data data; private OutputWorker outputWorker; @@ -62,15 +65,10 @@ void givenOutputWorkerCreated_ThenShouldBeRunning() { } @Nested - class OutputWorkerErrorHandlingTests { - @AfterEach - void tearDown() { - System.setErr(System.err); - System.setOut(System.out); - } + class OutputWorkerErrorHandlingTests extends BaseFagiTest { @Test - void givenWritingObjectGivesIOException_WhenRunningIsFalse_ThenShouldNotWriteToConsole() throws IOException { + void givenWritingObjectGivesIOException_WhenRunningIsFalse_ThenShouldNotLogError() throws IOException { var outContent = new ByteArrayOutputStream(); var errorContent = new ByteArrayOutputStream(); System.setErr(new PrintStream(errorContent)); @@ -84,14 +82,12 @@ void givenWritingObjectGivesIOException_WhenRunningIsFalse_ThenShouldNotWriteToC outputWorker.run(); - Assertions.assertAll( - () -> Assertions.assertFalse(outContent - .toString() - .contains("java.io.IOException")), - () -> Assertions.assertFalse(errorContent - .toString() - .contains("java.io.IOException")) + List> testLogRecords = lookupLogRecordsForClass( + OutputWorker.class, + TestLogLevel.ERROR ); + + Assertions.assertTrue(testLogRecords.isEmpty()); } @Test @@ -107,50 +103,91 @@ void givenWritingObjectsGivesException_WhenRunningIsTrue_ThenRunningShouldBeSetT } @Test - void givenWritingObjectsGivesIOException_WhenRunningIsTrue_ThenShouldWriteToConsoleAsInfo() throws IOException { - var outContent = new ByteArrayOutputStream(); - var errorContent = new ByteArrayOutputStream(); - System.setErr(new PrintStream(errorContent)); - System.setOut(new PrintStream(outContent)); - + void givenWritingObjectsGivesIOException_WhenRunningIsTrue_ThenShouldLogError() throws IOException { doThrow(new IOException()) .when(objOut) .writeObject(any()); outputWorker.addResponse("dummy"); + outputWorker.setUserName(username); outputWorker.run(); + List> testLogRecords = lookupLogRecordsForClass( + OutputWorker.class, + TestLogLevel.ERROR + ); + + Assertions.assertEquals( + 1, + testLogRecords.size() + ); + + TestLogRecord logRecord = testLogRecords.getFirst(); + Assertions.assertAll( - () -> Assertions.assertTrue(outContent - .toString() - .contains("java.io.IOException")), - () -> Assertions.assertFalse(errorContent - .toString() - .contains("java.io.IOException")) + () -> Assertions.assertEquals( + "Logging out user " + username, + logRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + logRecord.logLevel() + ), + () -> Assertions.assertInstanceOf( + IOException.class, + logRecord.throwable() + ) ); } @Test void givenWritingObjectsGivesException_WhenRunningIsTrue_ThenShouldLogoutUser() throws IOException { - var outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - doThrow(new IOException()) .when(objOut) .writeObject(any()); outputWorker.addResponse("dummy"); - outputWorker.setUserName("bob"); + outputWorker.setUserName(username); outputWorker.run(); - Mockito.verify(data, times(1)).userLogout("bob"); + Mockito + .verify( + data, + times(1) + ) + .userLogout(username); + + List> testLogRecords = lookupLogRecordsForClass( + OutputWorker.class, + TestLogLevel.ERROR + ); + + Assertions.assertEquals( + 1, + testLogRecords.size() + ); + + TestLogRecord logRecord = testLogRecords.getFirst(); - Assertions.assertTrue(outContent.toString().contains("Logging out user bob")); + Assertions.assertAll( + () -> Assertions.assertEquals( + "Logging out user " + username, + logRecord.message() + ), + () -> Assertions.assertEquals( + TestLogLevel.ERROR, + logRecord.logLevel() + ), + () -> Assertions.assertInstanceOf( + IOException.class, + logRecord.throwable() + ) + ); } } @Nested - class OutputWorkerEqualListsTests { + class OutputWorkerEqualListsTests extends BaseFagiTest { @Test void givenBothListsAreNull_WhenCallingEqualLists_ThenShouldReturnTrue() { Assertions.assertTrue(outputWorker.equalLists( @@ -252,7 +289,7 @@ void givenTwoListsHaveDifferentContent_WhenCallingEqualLists_ThenShouldReturnTru } @Nested - class OutputWorkerSendFriendRequestListTests { + class OutputWorkerSendFriendRequestListTests extends BaseFagiTest { @Test void givenNoUsernameInOutputWorker_WhenCheckingFriendRequestList_ThenShouldNotSendFriendRequestList() throws IOException { outputWorker.setIsRunningStrategy(new WorkerRunCalledNTimesStrategy(2)); @@ -463,16 +500,14 @@ void whenCheckingFriendRequestListTwentyTimes_ThenRunShouldTakeAtLeastTwoSeconds } @Nested - class OutputWorkerSendMessagesAndResponsesTests { + class OutputWorkerSendMessagesAndResponsesTests extends BaseFagiTest { @Test void givenMessagesQueueHasTwoMessages_WhenRunningIsTrue_ThenHaveSentTwoObject() throws IOException { - var outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - var user1LoggedInMessage = new UserLoggedIn("bob"); var user2LoggedInMessage = new UserLoggedIn("eve"); outputWorker.setIsRunningStrategy(new RunOnceStrategy()); + outputWorker.setUserName(username); outputWorker.addMessage(user1LoggedInMessage); outputWorker.addMessage(user2LoggedInMessage); @@ -490,6 +525,8 @@ void givenMessagesQueueHasTwoMessages_WhenRunningIsTrue_ThenHaveSentTwoObject() List messageObjects = captor.getAllValues(); + List> testLogRecords = lookupLogRecordsForClass(OutputWorker.class); + Assertions.assertAll( () -> Assertions.assertEquals( 2, @@ -499,16 +536,20 @@ void givenMessagesQueueHasTwoMessages_WhenRunningIsTrue_ThenHaveSentTwoObject() Conversion.convertToBytes(user1LoggedInMessage), messageObjects.getFirst() ), - () -> Assertions.assertTrue(outContent - .toString() - .contains(user1LoggedInMessage.toString())), + () -> Assertions.assertTrue(testLogRecords + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals("Sending the following object to user " + username + ": " + user1LoggedInMessage))), () -> Assertions.assertArrayEquals( Conversion.convertToBytes(user2LoggedInMessage), messageObjects.getLast() ), - () -> Assertions.assertTrue(outContent - .toString() - .contains(user2LoggedInMessage.toString())) + () -> Assertions.assertTrue(testLogRecords + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals("Sending the following object to user " + username + ": " + user2LoggedInMessage))) ); } @@ -581,45 +622,39 @@ void whenRunningIsFalse_ThenShouldSendAllRespondObjects() throws IOException { } @Nested - class OutputWorkerLoggingTests { - @AfterEach - void tearDown() { - System.setOut(System.out); - } - + class OutputWorkerLoggingTests extends BaseFagiTest { @Test - void whenRunningIsFalse_ThenClosingOutputShouldBePrintedToConsole() { - var outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - + void whenRunningIsFalse_ThenClosingOutputShouldBeLogged() { outputWorker.setIsRunningStrategy(new NeverRunStrategy()); outputWorker.run(); - Assertions.assertTrue(outContent - .toString() - .contains("Closing output")); + List> testLogRecords = lookupLogRecordsForClass(OutputWorker.class); + + Assertions.assertTrue(testLogRecords + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals("Closing output"))); } @Test - void whenRunningIsTrue_ThenRunningShouldBePrintedToConsole() { - var outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - + void whenRunningIsTrue_ThenRunningShouldBeLogged() { outputWorker.setIsRunningStrategy(new RunOnceStrategy()); outputWorker.run(); - Assertions.assertTrue(outContent - .toString() - .contains("Running")); + List> testLogRecords = lookupLogRecordsForClass(OutputWorker.class); + + Assertions.assertTrue(testLogRecords + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals("Running"))); } @Test void givenMessagesQueueHasOneMessage_WhenRunningIsTrue_ThenHaveSentOneObject() throws IOException { - var outContent = new ByteArrayOutputStream(); - System.setOut(new PrintStream(outContent)); - var userLoggedInMessage = new UserLoggedIn("bob"); outputWorker.setIsRunningStrategy(new RunOnceStrategy()); @@ -639,15 +674,18 @@ void givenMessagesQueueHasOneMessage_WhenRunningIsTrue_ThenHaveSentOneObject() t byte[] messageObject = captor.getValue(); - Assertions.assertAll( - () -> Assertions.assertArrayEquals( - Conversion.convertToBytes(userLoggedInMessage), - messageObject - ), - () -> Assertions.assertTrue(outContent - .toString() - .contains(userLoggedInMessage.toString())) + Assertions.assertArrayEquals( + Conversion.convertToBytes(userLoggedInMessage), + messageObject ); + + List> testLogRecords = lookupLogRecordsForClass(OutputWorker.class); + + Assertions.assertFalse(testLogRecords + .stream() + .anyMatch(logRecord -> logRecord + .message() + .equals(userLoggedInMessage.toString()))); } } diff --git a/shared/src/main/java/com/fagi/db/LogBasedDatabase.java b/shared/src/main/java/com/fagi/db/LogBasedDatabase.java index 9c369048..b4aeda95 100644 --- a/shared/src/main/java/com/fagi/db/LogBasedDatabase.java +++ b/shared/src/main/java/com/fagi/db/LogBasedDatabase.java @@ -1,7 +1,16 @@ package com.fagi.db; -import java.io.*; -import java.util.*; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -70,8 +79,8 @@ *

Error Handling:

*

The database handles various error conditions gracefully:

*
    - *
  • Corrupted Log entries are skipped with warnings to stderr
  • - *
  • Malformed entries are ignored during startup
  • + *
  • Corrupted Log entries are skipped with warnings logged to WARNING
  • + *
  • Malformed entries are ignored during startup and logged to WARNING
  • *
  • I/O errors during writes throw RuntimeException
  • *
* @@ -87,6 +96,7 @@ * @see com.fagi.utility.Checksum */ public class LogBasedDatabase implements AutoCloseable { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(LogBasedDatabase.class); enum DBOperation { PUT, @@ -125,11 +135,11 @@ enum DBOperation { * * @param filePath the path to the database Log file, must not be null * @throws DatabaseInitializeException if the database cannot be initialized due to: - *
    - *
  • I/O errors when creating/opening the Log file
  • - *
  • Permission issue with the file or directory
  • - *
  • Corruption in the existing Log file that prevents loading
  • - *
+ *
    + *
  • I/O errors when creating/opening the Log file
  • + *
  • Permission issue with the file or directory
  • + *
  • Corruption in the existing Log file that prevents loading
  • + *
*/ public LogBasedDatabase(String filePath) throws DatabaseInitializeException { this.logFilePath = filePath; @@ -144,7 +154,10 @@ public LogBasedDatabase(String filePath) throws DatabaseInitializeException { loadFromLog(); } catch (IOException e) { close(); - throw new DatabaseInitializeException("Failed to initialize database", e); + throw new DatabaseInitializeException( + "Failed to initialize database", + e + ); } catch (DatabaseInitializeException e) { close(); throw e; @@ -158,14 +171,14 @@ public LogBasedDatabase(String filePath) throws DatabaseInitializeException { * It bypasses normal file initialization and error handling. The provided * FileWriter must be properly configured for append mode.

* - * @param filePath the path to the database log file, used for identification + * @param filePath the path to the database log file, used for identification * @param logWriter the FileWriter to use for logging operations, must not be null * @throws DatabaseInitializeException if the database cannot be initialized due to: - *
    - *
  • I/O errors when creating/opening the Log file
  • - *
  • Permission issue with the file or directory
  • - *
  • Corruption in the existing Log file that prevents loading
  • - *
+ *
    + *
  • I/O errors when creating/opening the Log file
  • + *
  • Permission issue with the file or directory
  • + *
  • Corruption in the existing Log file that prevents loading
  • + *
*/ public LogBasedDatabase( String filePath, @@ -190,14 +203,12 @@ public LogBasedDatabase( *

Performance: O(1) time complexity. Constant time regardless * of database size.

* - * @param id the record identifier, must not be null - * @param key the field key, must not be null + * @param id the record identifier, must not be null + * @param key the field key, must not be null * @param value the field value, must not be null * @throws IllegalArgumentException if any parameter is null - * @throws DatabaseUpdateException if the write operation fails due to I/O errors - * - * @snippet - *
{@code
+     * @throws DatabaseUpdateException  if the write operation fails due to I/O errors
+     * @snippet 
{@code
      * db.put("user123", "name", "Alice");
      * db.put("user123", "email", "alice@example.com");
      * db.put("user123", "status", "active");
@@ -245,13 +256,11 @@ public void put(
      *
      * 

Performance: O(1) time complexity. Direct hash map lookup.

* - * @param id the record identifier to look up, must not be null + * @param id the record identifier to look up, must not be null * @param key the field key to retrieve, must not be null * @return the field value, or {@code null} if the record or field doesn't exist * @throws IllegalArgumentException if the id or key is null - * - * @snippet - *
{@code
+     * @snippet 
{@code
      * String email = db.get("user123", "email"); // Returns "alice@example.com" or null
      * String phone = db.get("user123", "phone"); // Returns null if field doesn't exist
      * String name = db.get("nonexistent", "name"); // Returns null if record doesn't exist
@@ -288,8 +297,7 @@ public String get(
      * @param id the record identifier to retrieve, must not be null
      * @return a copy of all fields for the record, or {@code null} if the record doesn't exist
      * @throws IllegalArgumentException if id is null
-     * @snippet
-     * 
{@code
+     * @snippet 
{@code
      * Map user = db.get("user123");
      * if (user != null) {
      *     String name = user.get("name");     // "Alice Johnson"
@@ -344,7 +352,8 @@ private void loadFromLog() throws DatabaseInitializeException {
 
                     String[] parts = line.split("\\|");
                     if (parts.length < 5) {
-                        System.err.println("Malformed entry at line " + lineNumber);
+                        int malformedLine = lineNumber;
+                        LOGGER.warning(() -> "Malformed entry at line " + malformedLine);
                         continue;
                     }
 
@@ -358,7 +367,8 @@ private void loadFromLog() throws DatabaseInitializeException {
                     );
 
                     if (!calculateChecksum(originalEntry).equals(storedChecksum)) {
-                        System.err.println("Corruption detected at line " + lineNumber);
+                        int corruptLine = lineNumber;
+                        LOGGER.warning(() -> "Corruption detected at line " + corruptLine);
                         continue;
                     }
 
@@ -405,9 +415,9 @@ private void processLogEntry(String[] parts) {
      * Appends an operation to the Log file with checksum verification.
      *
      * @param operation the operator type (e.g. "PUT")
-     * @param id the record identifier
-     * @param key the field key
-     * @param value the field value
+     * @param id        the record identifier
+     * @param key       the field key
+     * @param value     the field value
      * @throws DatabaseUpdateException if the write operations fails
      */
     private void appendToLog(
@@ -450,14 +460,12 @@ private void appendToLog(
      * 

Performance: O(n) where n is the total number of records. * Performance degrades linearly with database size.

* - * @param fieldName the name of the field to match against, must not be null + * @param fieldName the name of the field to match against, must not be null * @param fieldValue the value to search for, must not be null * @return a List or records (as maps) that contain the specified field-value pair, - * empty List if no matches found, never null + * empty List if no matches found, never null * @throws IllegalArgumentException if fieldName or fieldValue is null - * - * @snippet - *
{@code
+     * @snippet 
{@code
      * // Find all active users
      * List> activeUsers = db.queryByField("status", "active");
      *
@@ -466,7 +474,7 @@ private void appendToLog(
      *
      * // Process results
      * for (Map user : activeUsers) {
-     *     System.out.println("Active user: " + user.get("name"));
+     *     LOGGER.info(() -> "Active user: " + user.get("name"));
      * }
      * }
*/ @@ -499,15 +507,13 @@ public List> queryByField( *

Performance: O(1) time complexity. Direct size lookup.

* * @return the number of records currently stored in the database, never negative - * - * @snippet - *
{@code
+     * @snippet 
{@code
      * int totalUsers = db.count();
-     * System.out.println("Database contains " + totalUsers + " records");
+     * LOGGER.info(() -> "Database contains " + totalUsers + " records");
      *
      * // Check if database is empty
      * if (db.count() == 0) {
-     *     System.out.println("Database is empty");
+     *     LOGGER.info(() -> "Database is empty");
      * }
      * }
*/ @@ -535,12 +541,11 @@ public int count() { * only be called when no other operations are in progress.

* *

Error Handling: I/O errors during close are logged - * to stderr but do not throw exceptions.

+ * to ERROR but do not throw exceptions.

* *

This method is idempotent - calling it multiple times has no additional effect.

* - * @snippet - *
{@code
+     * @snippet 
{@code
      * LogBasedDatabase db = new LogBasedDatabase("myapp.db");
      * try {
      *     // Use database...
@@ -563,7 +568,10 @@ public void close() {
                     logWriter.close();
                 }
             } catch (IOException e) {
-                System.err.println("Error closing database: " + e.getMessage());
+                LOGGER.error(
+                        e,
+                        () -> "Error closing database."
+                );
             }
         }
     }
diff --git a/shared/src/main/java/com/fagi/encryption/AES.java b/shared/src/main/java/com/fagi/encryption/AES.java
index 666198f2..5da51d6b 100644
--- a/shared/src/main/java/com/fagi/encryption/AES.java
+++ b/shared/src/main/java/com/fagi/encryption/AES.java
@@ -1,6 +1,7 @@
 package com.fagi.encryption;
 
-import com.fagi.utility.Logger;
+import com.fagi.logging.FagiLogger;
+import com.fagi.logging.FagiLoggerFactory;
 
 import javax.crypto.BadPaddingException;
 import javax.crypto.Cipher;
@@ -17,6 +18,7 @@
  * Created by Marcus on 04-06-2016.
  */
 public class AES implements EncryptionAlgorithm {
+    private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(AES.class);
     byte[] iv = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
     IvParameterSpec ivspec = new IvParameterSpec(iv);
     private AESKey key;
@@ -44,8 +46,10 @@ public byte[] encrypt(byte[] msg) {
             cipher.init(Cipher.ENCRYPT_MODE, key.key(), ivspec);
             return cipher.doFinal(msg);
         } catch (NoSuchAlgorithmException | NoSuchPaddingException | BadPaddingException | InvalidKeyException | IllegalBlockSizeException | InvalidAlgorithmParameterException e) {
-            e.printStackTrace();
-            Logger.logStackTrace(e);
+            LOGGER.error(
+                    e,
+                    () -> "Failed to encrypt byte array."
+            );
         }
         return null;
     }
@@ -57,8 +61,10 @@ public byte[] decrypt(byte[] cipherText) {
             cipher.init(Cipher.DECRYPT_MODE, key.key(), ivspec);
             return cipher.doFinal(cipherText);
         } catch (NoSuchAlgorithmException | NoSuchPaddingException | BadPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException e) {
-            e.printStackTrace();
-            Logger.logStackTrace(e);
+            LOGGER.error(
+                    e,
+                    () -> "Failed to decrypt byte array."
+            );
         }
         return null;
     }
@@ -70,8 +76,10 @@ public void generateKey(int keyLength) {
             keygen.init(keyLength);
             this.key = new AESKey(keygen.generateKey());
         } catch (NoSuchAlgorithmException e) {
-            e.printStackTrace();
-            Logger.logStackTrace(e);
+            LOGGER.error(
+                    e,
+                    () -> "Failed to generate AES key."
+            );
         }
     }
 
diff --git a/shared/src/main/java/com/fagi/encryption/KeyStorage.java b/shared/src/main/java/com/fagi/encryption/KeyStorage.java
index f51f345d..50c27fcb 100644
--- a/shared/src/main/java/com/fagi/encryption/KeyStorage.java
+++ b/shared/src/main/java/com/fagi/encryption/KeyStorage.java
@@ -1,6 +1,7 @@
 package com.fagi.encryption;
 
-import com.fagi.utility.Logger;
+import com.fagi.logging.FagiLogger;
+import com.fagi.logging.FagiLoggerFactory;
 
 import java.io.File;
 import java.io.FileInputStream;
@@ -22,6 +23,7 @@
  * Created by Marcus on 04-06-2016.
  */
 public class KeyStorage {
+    private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(KeyStorage.class);
     private static final String KEYSFOLDER = "config/keys";
     public static final String PUBLICKEYFILE = KEYSFOLDER + "/public.key";
     public static final String PRIVATEKEYFILE = KEYSFOLDER + "/private.key";
@@ -71,8 +73,10 @@ public static KeyPair LoadKeyPair(String algorithm) throws IOException, NoSuchAl
         try {
             fis.read(encodedPrivateKey);
         } catch (IOException e) {
-            e.printStackTrace();
-            Logger.logStackTrace(e);
+            LOGGER.error(
+                    e,
+                    () -> "Failed to read encoded private key."
+            );
         }
         fis.close();
 
diff --git a/shared/src/main/java/com/fagi/encryption/RSA.java b/shared/src/main/java/com/fagi/encryption/RSA.java
index 655e7480..031f8a59 100644
--- a/shared/src/main/java/com/fagi/encryption/RSA.java
+++ b/shared/src/main/java/com/fagi/encryption/RSA.java
@@ -1,6 +1,7 @@
 package com.fagi.encryption;
 
-import com.fagi.utility.Logger;
+import com.fagi.logging.FagiLogger;
+import com.fagi.logging.FagiLoggerFactory;
 
 import javax.crypto.BadPaddingException;
 import javax.crypto.Cipher;
@@ -20,6 +21,7 @@
  * Created by Marcus on 30-05-2016.
  */
 public class RSA implements EncryptionAlgorithm {
+    private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(RSA.class);
     private RSAKey key;
     // TODO: Remove this as it is redundant. Trello task: https://trello.com/c/JZynEGUQ
     private PublicKey encryptionKey;
@@ -30,8 +32,10 @@ public RSA() {
             try {
                 key = new RSAKey(KeyStorage.LoadKeyPair("RSA"));
             } catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e) {
-                e.printStackTrace();
-                Logger.logStackTrace(e);
+                LOGGER.error(
+                        e,
+                        () -> "Failed to load existing RSA key pair."
+                );
             }
         } else {
             generateKey(4096);
@@ -61,8 +65,10 @@ public byte[] encrypt(byte[] msg) {
             cipher.init(Cipher.ENCRYPT_MODE, encryptionKey);
             return cipher.doFinal(msg);
         } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
-            e.printStackTrace();
-            Logger.logStackTrace(e);
+            LOGGER.error(
+                    e,
+                    () -> "Failed to encrypt byte array."
+            );
         }
         return null;
     }
@@ -78,8 +84,10 @@ public byte[] decrypt(byte[] cipherText) {
             );
             return cipher.doFinal(cipherText);
         } catch (NoSuchAlgorithmException | NoSuchPaddingException | BadPaddingException | IllegalBlockSizeException | InvalidKeyException e) {
-            e.printStackTrace();
-            Logger.logStackTrace(e);
+            LOGGER.error(
+                    e,
+                    () -> "Failed to decrypt byte array."
+            );
         }
         return null;
     }
@@ -91,8 +99,10 @@ public void generateKey(int keyLength) {
             keygen.initialize(keyLength);
             key = new RSAKey(keygen.generateKeyPair());
         } catch (NoSuchAlgorithmException e) {
-            e.printStackTrace();
-            Logger.logStackTrace(e);
+            LOGGER.error(
+                    e,
+                    () -> "Failed to generate RSA key."
+            );
         }
     }
 
diff --git a/shared/src/main/java/com/fagi/utility/Checksum.java b/shared/src/main/java/com/fagi/utility/Checksum.java
index a1a35b75..acb64f7e 100644
--- a/shared/src/main/java/com/fagi/utility/Checksum.java
+++ b/shared/src/main/java/com/fagi/utility/Checksum.java
@@ -40,7 +40,7 @@
  * // Calculate checksum for data integrity
  * String data = "PUT|user1|name|John Doe";
  * String checksum = Checksum.calculateChecksum(data);
- * System.out.println("Checksum: " + checksum); // e.g., "a1b2c3d4"
+ * LOGGER.info(() -> "Checksum: " + checksum); // e.g., "a1b2c3d4"
  *
  * // Verify data integrity
  * String receivedData = "PUT|user1|name|John Doe";
@@ -48,9 +48,9 @@
  * String calculatedChecksum = Checksum.calculateChecksum(receivedData);
  *
  * if (calculatedChecksum.equals(receivedChecksum)) {
- *     System.out.println("Data integrity verified");
+ *     LOGGER.info(() -> "Data integrity verified");
  * } else {
- *     System.out.println("Data corruption detected!");
+ *     LOGGER.warning(() -> "Data corruption detected!");
  * }
  * }
* @@ -112,7 +112,7 @@ private Checksum() { * * // Empty string handling * String emptyChecksum = Checksum.calculateChecksum(""); - * System.out.println("Empty string checksum: " + emptyChecksum); + * LOGGER.info(() -> "Empty string checksum: " + emptyChecksum); * * // Database log entry checksumming * String logEntry = "PUT|user123|email|john@example.com"; @@ -203,9 +203,9 @@ public static String calculateChecksum(String data, String encoding) * // Later, verify the data hasn't been corrupted * String retrievedData = "Important data"; * if (Checksum.verifyChecksum(retrievedData, storedChecksum)) { - * System.out.println("Data integrity verified"); + * LOGGER.info(() -> "Data integrity verified"); * } else { - * System.out.println("Data corruption detected!"); + * LOGGER.warning(() -> "Data corruption detected!"); * } * }
*/ diff --git a/shared/src/main/java/com/fagi/utility/JsonFileOperations.java b/shared/src/main/java/com/fagi/utility/JsonFileOperations.java index 4036e18a..e7902643 100644 --- a/shared/src/main/java/com/fagi/utility/JsonFileOperations.java +++ b/shared/src/main/java/com/fagi/utility/JsonFileOperations.java @@ -1,6 +1,8 @@ package com.fagi.utility; import com.fagi.conversation.Conversation; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; import com.google.gson.Gson; import java.io.BufferedReader; @@ -17,6 +19,7 @@ * Created by costa on 09-11-2016. */ public class JsonFileOperations { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(JsonFileOperations.class); public static final String FAGI_EXTENSION = ".fagi"; public static final String CONFIG_FOLDER_PATH = "config/"; public static final String CONVERSATION_FOLDER_PATH = "conversations/"; @@ -40,13 +43,18 @@ public static void storeObjectToFile( Gson gson = new Gson(); - PrintWriter out = new PrintWriter(new FileWriter(folderPath + fileName + FAGI_EXTENSION, false)); + PrintWriter out = new PrintWriter(new FileWriter( + folderPath + fileName + FAGI_EXTENSION, + false + )); out.println(gson.toJson(object)); out.flush(); out.close(); } catch (IOException e) { - e.printStackTrace(); - Logger.logStackTrace(e); + LOGGER.error( + e, + () -> "Failed to write object to file." + ); } } @@ -68,10 +76,15 @@ public static T loadObjectFromFile( Gson gson = new Gson(); - res = gson.fromJson(json.toString(), clazz); + res = gson.fromJson( + json.toString(), + clazz + ); } catch (IOException e) { - e.printStackTrace(); - Logger.logStackTrace(e); + LOGGER.error( + e, + () -> "Failed to load object from file." + ); } return res; } @@ -92,9 +105,12 @@ public static List loadAllObjectsInFolder( } for (File file : files) { - var loadedObj = loadObjectFromFile(file.getAbsolutePath(), clazz); + var loadedObj = loadObjectFromFile( + file.getAbsolutePath(), + clazz + ); if (loadedObj == null) { - System.err.println("Warning: We were about to add null due to this file path: " + file.getAbsolutePath()); + LOGGER.warning(() -> "We were about to add null due to this file path: " + file.getAbsolutePath()); continue; } res.add(loadedObj); @@ -104,7 +120,11 @@ public static List loadAllObjectsInFolder( } public static void storeConversation(Conversation c) { - storeObjectToFile(c, CONVERSATION_FOLDER_PATH, c.getId() + ""); + storeObjectToFile( + c, + CONVERSATION_FOLDER_PATH, + c.getId() + "" + ); } public static void storeClientConversation( @@ -114,14 +134,24 @@ public static void storeClientConversation( if (!clientFolder.exists()) { clientFolder.mkdir(); } - storeObjectToFile(c, username + "/" + CONVERSATION_FOLDER_PATH, c.getId() + ""); + storeObjectToFile( + c, + username + "/" + CONVERSATION_FOLDER_PATH, + c.getId() + "" + ); } public static List loadAllConversations() { - return loadAllObjectsInFolder(CONVERSATION_FOLDER_PATH, Conversation.class); + return loadAllObjectsInFolder( + CONVERSATION_FOLDER_PATH, + Conversation.class + ); } public static List loadAllClientConversations(String username) { - return loadAllObjectsInFolder(username + "/" + CONVERSATION_FOLDER_PATH, Conversation.class); + return loadAllObjectsInFolder( + username + "/" + CONVERSATION_FOLDER_PATH, + Conversation.class + ); } } diff --git a/shared/src/main/java/com/fagi/utility/Logger.java b/shared/src/main/java/com/fagi/utility/Logger.java deleted file mode 100644 index 59db7300..00000000 --- a/shared/src/main/java/com/fagi/utility/Logger.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.fagi.utility; - -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.io.PrintWriter; - -/** - * Created by costa on 02-04-2017. - */ -public class Logger { - public static final String LOGS_FOLDER_PATH = "logs/"; - - public static void logStackTrace(Exception e) { - long time = System.nanoTime(); - String filePath = LOGS_FOLDER_PATH + "log-error-" + time + JsonFileOperations.FAGI_EXTENSION; - File folder = new File(LOGS_FOLDER_PATH); - File logFile = new File(filePath); - if (!folder.exists()) { - folder.mkdir(); - } - try { - logFile.createNewFile(); - - PrintWriter out = new PrintWriter(new FileWriter(logFile), false); - e.printStackTrace(out); - out.flush(); - out.close(); - } catch (IOException ioe) { - e.printStackTrace(); - } - } -} diff --git a/shared/src/main/java/com/fagi/utility/NetworkUtility.java b/shared/src/main/java/com/fagi/utility/NetworkUtility.java index 80e9daa8..8f17ca1c 100644 --- a/shared/src/main/java/com/fagi/utility/NetworkUtility.java +++ b/shared/src/main/java/com/fagi/utility/NetworkUtility.java @@ -1,5 +1,8 @@ package com.fagi.utility; +import com.fagi.logging.FagiLogger; +import com.fagi.logging.FagiLoggerFactory; + import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; @@ -10,6 +13,8 @@ * Used for utility functions related to network */ public class NetworkUtility { + private static final FagiLogger LOGGER = FagiLoggerFactory.createLogger(NetworkUtility.class); + /** * Calls external service to find the public IP of the machine the server is running on * @@ -17,16 +22,21 @@ public class NetworkUtility { */ public static String getExternalIP() { String ip = ""; + String checkIPServiceUrl = "http://checkip.amazonaws.com"; try { URL whatismyip = URI - .create("http://checkip.amazonaws.com") + .create(checkIPServiceUrl) .toURL(); BufferedReader in = new BufferedReader(new InputStreamReader(whatismyip.openStream())); ip = in.readLine(); } catch (IOException e) { - System.err.println("Could not get public it. Cause: " + e); + LOGGER.error( + e, + () -> "Could not get public ip from " + checkIPServiceUrl + ); } + return ip; } } diff --git a/shared/src/test/java/com/fagi/db/LogBasedDatabaseConcurrencyTests.java b/shared/src/test/java/com/fagi/db/LogBasedDatabaseConcurrencyTests.java index efaa3658..a9cf0857 100644 --- a/shared/src/test/java/com/fagi/db/LogBasedDatabaseConcurrencyTests.java +++ b/shared/src/test/java/com/fagi/db/LogBasedDatabaseConcurrencyTests.java @@ -1,14 +1,23 @@ package com.fagi.db; -import org.junit.jupiter.api.*; +import com.fagi.BaseFagiTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; -import java.util.*; -import java.util.concurrent.*; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -public class LogBasedDatabaseConcurrencyTests { +public class LogBasedDatabaseConcurrencyTests extends BaseFagiTest { private LogBasedDatabase db; @BeforeEach diff --git a/shared/src/test/java/com/fagi/db/LogBasedDatabaseCorruptionTests.java b/shared/src/test/java/com/fagi/db/LogBasedDatabaseCorruptionTests.java index 4744a98a..4b1ba985 100644 --- a/shared/src/test/java/com/fagi/db/LogBasedDatabaseCorruptionTests.java +++ b/shared/src/test/java/com/fagi/db/LogBasedDatabaseCorruptionTests.java @@ -1,15 +1,25 @@ package com.fagi.db; -import org.junit.jupiter.api.*; +import com.fagi.BaseFagiTest; +import com.fagi.logging.TestLogLevel; +import com.fagi.logging.TestLogRecord; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import java.io.*; -import java.nio.file.*; -import java.util.*; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.List; import static com.fagi.utility.Checksum.calculateChecksum; -public class LogBasedDatabaseCorruptionTests { +public class LogBasedDatabaseCorruptionTests extends BaseFagiTest { private LogBasedDatabase db; private String dbPath; @@ -53,14 +63,27 @@ void testCorruptedChecksum() throws IOException, DatabaseInitializeException, Da corruptedContent ); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - System.setErr(new PrintStream(out)); - db = new LogBasedDatabase(dbPath); + List> logRecords = lookupLogRecordsForClass(LogBasedDatabase.class); + + Assertions.assertEquals( + 1, + logRecords.size() + ); + + Assertions.assertEquals( + "Corruption detected at line 2", + logRecords + .getFirst() + .message() + ); + Assertions.assertEquals( - "Corruption detected at line 2" + System.lineSeparator(), - out.toString() + TestLogLevel.WARNING, + logRecords + .getFirst() + .logLevel() ); Assertions.assertEquals( @@ -133,15 +156,28 @@ void testOperationsAfterRecovery() throws IOException, DatabaseInitializeExcepti StandardOpenOption.APPEND ); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - System.setErr(new PrintStream(out)); - // Reload and continue operations db = new LogBasedDatabase(dbPath); + List> logRecords = lookupLogRecordsForClass(LogBasedDatabase.class); + + Assertions.assertEquals( + 1, + logRecords.size() + ); + + Assertions.assertEquals( + "Malformed entry at line 3", + logRecords + .getFirst() + .message() + ); + Assertions.assertEquals( - "Malformed entry at line 3" + System.lineSeparator(), - out.toString() + TestLogLevel.WARNING, + logRecords + .getFirst() + .logLevel() ); // Should be able to continue normal operations diff --git a/shared/src/test/java/com/fagi/db/LogBasedDatabaseIntegrationTests.java b/shared/src/test/java/com/fagi/db/LogBasedDatabaseIntegrationTests.java index 11a8fee3..8efc2502 100644 --- a/shared/src/test/java/com/fagi/db/LogBasedDatabaseIntegrationTests.java +++ b/shared/src/test/java/com/fagi/db/LogBasedDatabaseIntegrationTests.java @@ -1,19 +1,25 @@ package com.fagi.db; -import org.junit.jupiter.api.*; +import com.fagi.BaseFagiTest; +import com.fagi.logging.TestLogRecord; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mockito; -import java.io.*; -import java.nio.file.*; -import java.util.*; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Map; import static com.fagi.utility.Checksum.calculateChecksum; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; -class LogBasedDatabaseIntegrationTests { - +class LogBasedDatabaseIntegrationTests extends BaseFagiTest { @Test void testCloseIOException(@TempDir Path tempDir) throws IOException, DatabaseInitializeException { FileWriter mockWriter = Mockito.mock(FileWriter.class); @@ -21,26 +27,44 @@ void testCloseIOException(@TempDir Path tempDir) throws IOException, DatabaseIni .when(mockWriter) .close(); Path dbPath = tempDir.resolve("test.db"); - var ignored = dbPath.toFile().createNewFile(); + var ignored = dbPath + .toFile() + .createNewFile(); LogBasedDatabase db = new LogBasedDatabase( dbPath.toString(), mockWriter ); - // Capture System.err output to verify error message - ByteArrayOutputStream errOutput = new ByteArrayOutputStream(); - PrintStream originalErr = System.err; - System.setErr(new PrintStream(errOutput)); - db.close(); - String errorOutput = errOutput.toString(); - Assertions.assertTrue(errorOutput.contains("Error closing database")); - Assertions.assertTrue(errorOutput.contains("Disk full")); + List> logRecords = lookupLogRecordsForClass(LogBasedDatabase.class); + + Assertions.assertEquals( + 1, + logRecords.size() + ); + Assertions.assertEquals( + "Error closing database.", + logRecords + .getFirst() + .message() + ); + Assertions.assertInstanceOf( + IOException.class, + logRecords + .getFirst() + .throwable() + ); + Assertions.assertEquals( + "Disk full", + logRecords + .getFirst() + .throwable() + .getMessage() + ); verify(mockWriter).close(); - System.setErr(originalErr); } @Test diff --git a/shared/src/test/java/com/fagi/db/LogBasedDatabasePerformanceTests.java b/shared/src/test/java/com/fagi/db/LogBasedDatabasePerformanceTests.java index 8a1ac3dc..a8488bcb 100644 --- a/shared/src/test/java/com/fagi/db/LogBasedDatabasePerformanceTests.java +++ b/shared/src/test/java/com/fagi/db/LogBasedDatabasePerformanceTests.java @@ -1,17 +1,23 @@ package com.fagi.db; -import org.junit.jupiter.api.*; +import com.fagi.BaseFagiTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; -import java.util.*; +import java.util.List; +import java.util.Map; +import java.util.Random; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -public class LogBasedDatabasePerformanceTests { +public class LogBasedDatabasePerformanceTests extends BaseFagiTest { private LogBasedDatabase db; private String dbPath; diff --git a/shared/src/test/java/com/fagi/db/LogBasedDatabaseResourceTests.java b/shared/src/test/java/com/fagi/db/LogBasedDatabaseResourceTests.java index 3be62eaa..c9075a4b 100644 --- a/shared/src/test/java/com/fagi/db/LogBasedDatabaseResourceTests.java +++ b/shared/src/test/java/com/fagi/db/LogBasedDatabaseResourceTests.java @@ -1,12 +1,13 @@ package com.fagi.db; +import com.fagi.BaseFagiTest; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; -public class LogBasedDatabaseResourceTests { +public class LogBasedDatabaseResourceTests extends BaseFagiTest { @Test void testMemoryUsage(@TempDir Path tempDir) throws DatabaseInitializeException, DatabaseUpdateException { String dbPath = tempDir diff --git a/shared/src/test/java/com/fagi/db/LogBasedDatabaseTests.java b/shared/src/test/java/com/fagi/db/LogBasedDatabaseTests.java index da31133e..3d843486 100644 --- a/shared/src/test/java/com/fagi/db/LogBasedDatabaseTests.java +++ b/shared/src/test/java/com/fagi/db/LogBasedDatabaseTests.java @@ -1,5 +1,6 @@ package com.fagi.db; +import com.fagi.BaseFagiTest; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -9,7 +10,7 @@ import java.nio.file.Paths; import java.util.Map; -public class LogBasedDatabaseTests { +public class LogBasedDatabaseTests extends BaseFagiTest { private String dbPath; @BeforeEach From 79ef4b518a2e27881c74c5aec8474952377e7af3 Mon Sep 17 00:00:00 2001 From: zargess Date: Sun, 14 Dec 2025 10:41:51 +0100 Subject: [PATCH 6/7] Made framework print more sensible log level names Changed what log level names are printed in console and in files to match the names of the methods of the FagiLogger Fixed deletion of old log files generated by tests --- .../logging/java/JavaLoggerFormatter.java | 14 +++++++++- .../java/JavaLoggerConfigStrategyTest.java | 27 +++++++++++++++---- .../logging/java/JavaLoggerFormatterTest.java | 4 +-- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/shared/src/main/java/com/fagi/logging/java/JavaLoggerFormatter.java b/shared/src/main/java/com/fagi/logging/java/JavaLoggerFormatter.java index df988ace..5fbfb5ee 100644 --- a/shared/src/main/java/com/fagi/logging/java/JavaLoggerFormatter.java +++ b/shared/src/main/java/com/fagi/logging/java/JavaLoggerFormatter.java @@ -5,6 +5,7 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.logging.Formatter; +import java.util.logging.Level; import java.util.logging.LogRecord; /** @@ -19,6 +20,7 @@ *

*

* 2025-07-19 09:42:37 [INFO] com.fagi.server.Server - Starting Server + * 2025-07-19 09:42:40 [DEBUG] com.fagi.server.Server - Starting Server took 3 ms *

*

* If there is an exception in the log, the stacktrace will start on the next line. @@ -42,7 +44,7 @@ public String format(LogRecord record) { sb.append(String.format( " [%s] %s - %s%n", - record.getLevel(), + transformLogLevel(record.getLevel()), loggerName, formatMessage(record) )); @@ -57,4 +59,14 @@ public String format(LogRecord record) { return sb.toString(); } + + private String transformLogLevel(Level level) { + return switch (level.getName()) { + case "SEVERE" -> "ERROR"; + case "WARNING" -> "WARNING"; + case "INFO" -> "INFO"; + case "FINE" -> "DEBUG"; + default -> level.getName(); + }; + } } diff --git a/shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java b/shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java index cf4a654c..303c20c2 100644 --- a/shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java +++ b/shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java @@ -9,6 +9,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.Comparator; import java.util.List; import java.util.logging.ConsoleHandler; import java.util.logging.FileHandler; @@ -16,6 +17,7 @@ import java.util.logging.Level; import java.util.logging.LogRecord; import java.util.logging.Logger; +import java.util.stream.Stream; /** * Doesn't utilise {@link com.fagi.BaseFagiTest} as we want to test the real logging framework to check if the @@ -33,12 +35,27 @@ static void setupClass() throws IOException { // Deleting the "build/test_logs" folder before executing the test. // This is done to mitigate Windows locking the log files in such a way that they cannot be // deleted after running the tests. - tempDir - .toFile() - .delete(); + if (Files.exists(tempDir)) { + try (Stream walk = Files.walk(tempDir)) { + walk + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + throw new RuntimeException( + "Failed to delete: " + path, + e + ); + } + }); + } + } - // Create the "build/test_logs" folder - Files.createDirectory(tempDir); + if (!Files.exists(tempDir)) { + // Create the "build/test_logs" folder + Files.createDirectory(tempDir); + } } @BeforeEach diff --git a/shared/src/test/java/com/fagi/logging/java/JavaLoggerFormatterTest.java b/shared/src/test/java/com/fagi/logging/java/JavaLoggerFormatterTest.java index 2196e652..65a73c74 100644 --- a/shared/src/test/java/com/fagi/logging/java/JavaLoggerFormatterTest.java +++ b/shared/src/test/java/com/fagi/logging/java/JavaLoggerFormatterTest.java @@ -14,7 +14,7 @@ class JavaLoggerFormatterTest { @Test void testFormatterShouldGiveCorrectFormattedStrings() { var logRecord = new LogRecord( - Level.WARNING, + Level.FINE, "This is the log message" ); logRecord.setLoggerName(JavaLoggerFormatterTest.class.getName()); @@ -22,7 +22,7 @@ void testFormatterShouldGiveCorrectFormattedStrings() { String dateTimeString = formatter.dateFormat.format(new Date(logRecord.getMillis())); - var expectedFormattedLogEntry = dateTimeString + " [" + logRecord.getLevel() + "] " + logRecord.getLoggerName() + " - " + logRecord.getMessage() + System.lineSeparator() + IOException.class.getName(); + var expectedFormattedLogEntry = dateTimeString + " [DEBUG] " + logRecord.getLoggerName() + " - " + logRecord.getMessage() + System.lineSeparator() + IOException.class.getName(); // Normalize line endings such that the test works on different operating systems String formattedLogRecord = formatter From f9fb3cb60aaee0559c383990753058bd05acf462 Mon Sep 17 00:00:00 2001 From: zargess Date: Sun, 4 Jan 2026 22:03:54 +0100 Subject: [PATCH 7/7] fix: Fix failed test in JavaLoggerConfigStrategyTest by making log file names generated from random UUID --- .../com/fagi/logging/java/JavaLoggerConfigStrategyTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java b/shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java index 303c20c2..3c59bfc7 100644 --- a/shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java +++ b/shared/src/test/java/com/fagi/logging/java/JavaLoggerConfigStrategyTest.java @@ -11,6 +11,7 @@ import java.util.Arrays; import java.util.Comparator; import java.util.List; +import java.util.UUID; import java.util.logging.ConsoleHandler; import java.util.logging.FileHandler; import java.util.logging.Handler; @@ -278,7 +279,7 @@ void testCustomConfigCheckIsFalseWhenPropertyIsNull() { } private static Path createLogFile() { - return tempDir.resolve("test_" + System.currentTimeMillis() + ".log"); + return tempDir.resolve("test_" + UUID.randomUUID() + ".log"); } private void cleanHandlers() {