From 150c48bf1e84008f85827cf87f8cfef656b695a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Mon, 13 Jul 2026 09:28:14 +0100 Subject: [PATCH] Add Playwright E2E coverage for moderator flows (#875) --- .github/workflows/e2e.yaml | 3 +- client/public/e2e/tone-3s.wav | Bin 0 -> 264678 bytes server/.gitignore | 4 + server/e2e/fixtures/coop.ts | 186 +++++++++++++++++++- server/e2e/fixtures/media.ts | 11 ++ server/e2e/playwright.config.ts | 9 +- server/e2e/tests/investigation.spec.ts | 38 ++++ server/e2e/tests/item-signals.spec.ts | 60 +++++++ server/e2e/tests/item-type-creation.spec.ts | 48 +++++ server/e2e/tests/login.spec.ts | 18 +- server/e2e/tests/mrt-job-review.spec.ts | 111 ++++++++++++ server/e2e/tests/rule-creation.spec.ts | 72 ++++++++ server/e2e/tests/rule-routing.spec.ts | 67 +++++++ server/utils/encoding.ts | 2 +- 14 files changed, 620 insertions(+), 9 deletions(-) create mode 100644 client/public/e2e/tone-3s.wav create mode 100644 server/e2e/fixtures/media.ts create mode 100644 server/e2e/tests/investigation.spec.ts create mode 100644 server/e2e/tests/item-signals.spec.ts create mode 100644 server/e2e/tests/item-type-creation.spec.ts create mode 100644 server/e2e/tests/mrt-job-review.spec.ts create mode 100644 server/e2e/tests/rule-creation.spec.ts create mode 100644 server/e2e/tests/rule-routing.spec.ts diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 0daab5a..5211d63 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -82,6 +82,7 @@ jobs: cp server/.env.example server/.env cp db/.env.example db/.env cp client/.env.example client/.env + sed -i "s/^ITEM_QUEUE_TRAFFIC_PERCENTAGE=.*/ITEM_QUEUE_TRAFFIC_PERCENTAGE='1'/" server/.env - name: Install dependencies run: | @@ -144,7 +145,7 @@ jobs: PLAYWRIGHT_BASE_URL: http://localhost:3000 - name: Upload Playwright report - if: ${{ !cancelled() }} + if: ${{ failure() }} uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: playwright-report diff --git a/client/public/e2e/tone-3s.wav b/client/public/e2e/tone-3s.wav new file mode 100644 index 0000000000000000000000000000000000000000..755a5c30bb24be30e24772ba3c019cadbaa89a20 GIT binary patch literal 264678 zcmWIYbaT7M$-)rs80MOmTcRMqz`(%Bz{udThJm3Yf{}rVL4d)>GdM(wfq}u(&&}U6 z*e!&Qfq}s%u`JEZNYBtn&(Oe#AtkXSaRw&~14#3K#=lHInBTEHU_HaOfxVX_iBp&B z9oG_WKb|){HN5|MOZjf|8S_{3Z{feo|Be4Y{}29${JZ#@`EB^0@ip=B@%8fl< z{cFLOqo372PyWRJX~su`kJmo*d%pjX?7zvCstpU5aw{E2*wD8@c-feBLBwzN&Q{)tL5k1AA7$) z`zHP^`0LCsuRnW!-uy}D)25GZA76f$^C9+w{s+MiOdnW2h<-5tkojTNhujL*DZ_I;`P>hMkA`^)dAe{BA_=-2e$6aMu4?f%#OzlWiRv74!#xt^t%HIdDe zU5kT>^AhKDu3&B^o@G2XyvKMQ`PTFC^2hMc;XlLwmj5sRU;elJXZh#x$MN&?Z{%~~ zJIU+7yOM{ECxUw}*L6+~P6G~q_H?#N)>f8Y=7~&`7$-4I{6F#Eq`%Yt%=^9a*RG!z zf4u)L_TBYc{ntZZ1i$2bzVgZQ)0vO4AOC(>^C9bl#RstuEFYLY2z=1{5c6Tyhvy%h zKd%3%@oB>+m(R~WPx<2aRp{G;Z`;35|55id=U4pi@IS$S1OEm74`K*p3}EtQc4x6+ z)n*fA|G|EVV+m&tmnQdZ?j{~--W9xReAD@U^7-)h^B?Aa&i|YLKmRBGYy7MD^Z1qc zPw>U@J?Bm2J_eGc0O*1ar8nNKmDWjxDp=Kq<0XZ~LJbM5zo zU+;gi{#5$m_PylWs;_UpSbUlI`P-+APtQM=eiZt+_d~@8rw_6p*gr6S;QpZYA>c#z zhbtd+KTiF~{b|A{rOyXG=X_EA`sC||Z*AXGet7;g{iXg}?vLbOv43L!#TmpI#hFBy z`B+$4zp*}KJIcP0qmp#~P?pU6mJe|DUeD!=!_^kLF`FHR?;Qzt@pZ`1mJ^roy zwfv_1_xLLKnE4ubKkx+ctl<94WyF=vIhkV{`$e{AtY2CFF|#nUF|jkUF|hq-`^WK@ z?~nLz)n8UWLw=NhU;ORHSMje2U$%Xg_}u=9`BUFV^^fO2w0-dXpz?w11LFsd4{{$| zKh%6U^g-fd&Bym23qSq;H2bs5mmgntd~N&|^4;Kv$j|>j-~W30`@x^PfA9Re`~NP( zUB=r?*Ok3y7w>i&so=o2NylH&r_%!&7_}B8^;Qzw^pZ^#C zQ~m?|-TdzSpZO;6$@9(U733}Fxxg*U9nUq5^9aXFb~bhyHXT-T7F%WqCPzjG28aI+ z|D681{qg@D^DF;n$B&iYFMVVE=J>Vq%k9rrpO<}7__X|^?Z<~7rhbV0p!0$M1JeiA z4-y}&KjeMb_<{Lj%Ev1ogFfB+RQ_4!%egPzUxU7>eE;|T!H<(acm7)Yd-&w4?-V|J|uov@ZtRjpN~5~nta;(DfsiZ&r83=e^vSR`P=F5Yko}m+4QUIckZ9e zzZw5B{%0^`GG;KPFvqe4u{yG8vGZ^|HW+0 zx9J~)zk2`l{_8R5GwLyEGpn*lvvRY2W4p<|g`=G_kV}C32zL$-JMUCpF}_Z|H++u# z?fiTBAM^j@|Ih!G|2F?d{&Idj{;Pa>eBXJCc^~mO^UUCW$fdv)%GtoNh`=gZ$O|GxbH%J7BpBhwq^$1GP_53(&|Z{~>LRN;EcHHX`s=P6Ga?=Rj0 zzUzGY{1yD0_;2%n<^RwBlm8L_9{yH-2mV)lZG6IflX+QqGkFeh^KkoewQz3WxXS*K zjg3u=Re?o~S(8bVQHw$Izve&fzXpFSf4lvP`dRX0>i7NMK7Td%TJhz~XPwWpK8bvq z`_bg%tq=VlLOy7G;04!(QXlL;lziCnf%{|b$NL}SKE3|b`dR18{V#LBrhYU3F7)H; zkNZE*|2pz}-=AH7cmCV?e;30p#vM#snAfn(XYFAtW)I@f>r_Z;MSBe+c_{xjz90Cp z^5^tl-M?G@H2kgqSO33(p@FfUshYWjC4)7b&7NI><0r=n&VDX0?qA&Vc+7YY@Y?XL z;^X9xcap8g{FrR4MNPyU}S zeoXqv_;LM*+z(bC#6PfpVEVxSLFYr%hv^?4f3W|!>Z8)9m7nZB-~Zh6#q}%aw`H3ehU25`4RBF_S@#KU%oheneqA0 zr@T*ZKURGd`*`p}^#_*^a?rX^<%8dc_74|7XnvgVk@ZvOC+W{SKc{?=`g;58@^3ZY zBY)Wa)cPg!Tlf#(U+#Zg|G61>7`d4^nVDIBvb<)!#*pLm*i zS^28?9`Kp-*YR)Tzt8_2To>Nw-_GB_Z^{3Nua=LKuZ{N`PZZAv?%!P2T!ox-IS#Pj zVf)DXpM{HspIL}Wm{Ev9@W0SMk-w6Elztoha`_qmqw)KiZx6o8f6e%^_p|)x-cOvL zCV$lac=}eb}oSa;zxth5RcrNfH z^ScfP57Dtt?MC3x$2Zg8t|r*qBcJjd~w zU65U!&79SV#e><0$(PZG!TZ1WKi|JWf1-Y;|El^q`N!t(cfRp`^ZGjR%j3^ZpVxj; z`?ThxVEG~Y!`cu3Kg54L_tEFm^-l$#MZX;T()88yo7DHu z->?4I_jB#9MZahNnelhpzv=&HFw9__#x#k!i=~z|hb@@hgoB6kHs=DaXl^c^wLH$e zXL#NDHuDMc$MetUKga)$AJkfW!+(Z;||AK&QdNz?#JAnJW9Ol zcy;*Z@crfU=byxXl>Y_)Z~lM$ANjBFFXzwZm*+pi7s>aKH;VTdk19_E_kONFoO+z$ z92M-7*p{+xV%g2SpXnguL52hW5Bxj$_sE}9zpws!`t#Qhi61uKv%W3-`s9n@m#)w6 zKP7#7^s(S0@5fyqNk}uj{-+kTn zZNm4wA0a;-ei{AN{G;+$>7UYnWd>zNB_;)CDHZ`%Mz+^%=h;_t)Ny)nv2*X?PT^tT z?dKKXYvFsrXUE^dznlLdxV89={|^6V{wjV${+oQoe1CbXcwh2(^DN+g#ihv=$JxcP zhW$9(ZPr&TpP7F!{bu~l@caL-f4~0z{loN!=eN`^gP-0%^1jddcK$2h*N89cJ_~+s z`1J2%=SQWFr$01*@cy6#Z7nK%aQ{&M;n)Y6kBuL{ek}jQ_IdGVpD&DG4}9(V7Wdui zhx|{jU%!8S{QdgRi@z`az4-r<;U(iUriaWoSWdBSW1GWX#o^B>&2^V+3b#GaJ)Q#I zFTB}&m-%$~OZhkO-{J?g7Ju?T;oryK!SBrfp0Arvl5Zw2H*Y@A32ssDFs@$CT^#q> z|F8+NDY9y_7&4nMnKGI(nEW^SXa3jvkIV1CUnxK9e$4-V^4p)UmR}pbT={JLdBG>C zPm4ZUe!TZ#(uc4QS|9j6Fn(bDAo;=eL*a)lAJ{);e!TfH;?t8)b)QwfT>CQRYs@#@ z?;Jl~{kZb;$giEhH~m@vciq2r|JO0BV_eI$f_VYU6xK$z40aC=dCqs7o4B&MC3$x9 z1n^$x4dmOyC&{12zl8r1{|9hg_=^7o|1|y(erEo~d?tL`d3AWF@Vw`?<8I>G&H0)` zki&r8oh^bjnI(%kpQ(VcfFb{X{=b61C4Xvucl?_9bK{ST-+zA7{Fd@{<(FTd!#^MV zWd3Q-NB@r>KP>u?^uhRpFt{!h{$TPU<-_6+pFaeC-1pJy)8S81pZ|Vd`z7P6-nYNs zu6^J3W8Tl6Uk$%2|CIhM{#X3JgrS77n5lp{lO>kbhs~5-jN?7We$F;7C+<(&(|PoH zck!C@E#+h7596QCf0F+dxV8A6|04fl{#1T({#|_jd{=pWd3W%L@nmqX<9f*{&FRdM z!d}bP&pL-?3G)i3Rg9|`R{me{Z{^>$e>VT#|LgqEr$1PJXnv3QHsR~#FN$BPKR^5w z`sw<|^pC6`H-9MjVDmxp1E?8kN-dV_xSJgKW~12`^EZ8>ZjR{sPFCH_I~~Q#pBET&&;1o zK7IOF|557Wkq>nr+&{>F;P}A!f$M|v2cHkEAI^SI`PluD;ZyS`kAZGP>qubj``=nv>BrC!=dlM%SE-t~mj% zzZ_k2GP>qubj``=nv>BrC!=dlM%SE-t~nXqtuwk?XLPsD=x&|S-8!SYbw+pVjPBOq z8{Mrlx?5*-w+{d4Zk^HHI-|RFMtAFs?$#OItuuPg325i)=s72&=bVh5b256)$>=#J zqvxEAo^vvK&dKOGC!^<_jGl8cdd|t{IVYp%oQ$4xGJ4KQ@8}7!qbJ0Uo)9~FLhR@X zv7;x%j-C)ZdP3~z39+Lm#Eza2J9M(@@cy<2DWZk^G)bw=;j8NFL)^lqKeyLCqI))~E9XY_8J(Ytj<@75W;m3Z`4 z;?Y})M{gw_y_II z@#M$RkrPes+uTh&(!48p)%d3K{p9oE@8=&KIT;-}867zp9XS~tIT;-}867zp9XS~t zIT;-}867zp9XS~tIT;-}867zp9XS~tIa$J)!!_+*=*Y?F$jRu)$>_+*=*Y?F z$jRu)$>^Gs(KRQdYfeVjoQ$qH8C`QSy5_`)E1h$6&B?=*Y?F$jRu)$>_+*=*Y?F$jRu)$>_+*=*Y?F$jRu)$>_+*=*Y?F$jRu) z$>_+*fBvuhqa!D7zoLGY{FwTE|F_Ry4Zc=_+*=*Y?F z$jRu)$>_+*=*Y?F$jRu)$>_+*=*Y?F$jRu)$>_+5!5_=tqa!D$J|%u;{j&8-;aBr- z?B5@JKk#Ga&(V>S(UFtUk(1GplhKir(UFtUk(1GplhKir(UFtUk(1GplhKir(UFtU zk(1GplhKirBOe_;jgFjL=C0xqfz{V+Om67xTnA(kV|3(XbmU}ot?r-eB(UFtQ{8jvh{5ScE`Tp`&@xJ8o=2^f!I&v~Paxyw{GCFcH zI&v~Paxyw{GCFcHI&v~Paxyw{GCFcHI&v~Paxyw{GCFcHI&$)j{|^7?$Vtl2x*zku zpZxactL4{*FIPSre_rrubmU}o_+*=*Y?F$jRu)$>_+*=*Y?F$jRu)$>_+* z=*Y?F$jRu)$>_+*=*Y?F$cY5^Y3|XHlV@yySh-n6S)`cdnB*Ac7-av;{u>=R867zp z9XS~tIT;-}867zp9XS~tIT;-}867zp9XS~tIT;-}867zp9XS~tIT;-}S1HUDY<9UVCt9XS~tIT;-}867zp9XS~tIT;-} z867zp9XS~tIT;-}867zp9XS~tIT;-}867#f%KnjUbmYYFgU|=&56mA#KA3(;`>_1O z_YYwoM@LRZM@~jZPDV#gMn_IYM@~jZPDV#gMn_IYM@~jZPDV#gMn_IYM@~jZPDV#g zMn_KOe@OT+I&$L1=*r~CY|UcGs>sI0{+9g&$1Kj#k(1GplhKir(UFtUk(1GplhKir z(UFtUk(1GplhKir(UFtUk(1GplhKir(UFtUkrUVdZVaO%Czkw=_-gq$`Pz8D@kH@# z;Qq~J%{4l5GCFcHI&v~Paxyw{GCFcHI&v~Paxyw{GCFcHI&v~Paxyw{GCFcHI&v~P zaxyw{vYo$ye{|$z^Y=U7_`Z34o%rSPXQ$6=KdF6M^Ko?KWOU?YbmU}oCfoM$>_+*=*Y?F$jRu)$>_+*=*Y?F$jRu)$>_+*=*Y?F$jRu)$>_+* z=*Y?F$jRu)iO=VEpGQYd`gsNTTKHb@+3~mV@8*BV|AYTO|LDlc=*Y?F$jRu)$>_+* z=*Y?F$jRu)$>_+*=*Y?F$jRu)$>_+*=*Y?F$jRu)$>_*Q3J(MC=*Wp8t2T=vvk8+a zqbY;Qf0KXaf35$Fj+~5+oQ#f~jE$s$1#yJifeS_WOU?YbmU}o7(1n(UFtUk(1GplhKir(UFtUk(1GplhKir(UFtUk(1GplhKir z(UFtUk(1GplhKir(UFt6KlXkf9Xav)D)jBax9#7j|ET+!^DF*$_@Cgvqa!DyBPXLH zC!-@Lqa!DyBPXLHC!-@Lqa!DyBPXLHC!-@Lqa!DyBPXLHC!-@Lqa!EJK2P~FI z zSI_r^&x*g1e+T~q{vZ7R`M>jzj+~5+oQ#f~jEEs<9IWcFkWp-e4WOQI~`0wz~>95-#|KFn{C!-@L zqa!DyBPXLHC!-@Lqa!DyBPXLHC!-@Lqa!DyBPXLHC!-@Lqa!DyBPXLHCo*h0tfM0* z%paIP2z@a6koaN2hxZ?RKJNTz@@aJBWOU?YbmU}oGwLyo zj-0gd3G+?nW#P@_Il#@s?aS4|xq)MJ1IuM59?{v7am*C(S-+dq1LeEVT^ z-ukW(|kdlPbOkeCGUh{M-2N^MB|6&;OnO zKL6;*$>_+*=*Y?F$jRu)$>_+*=*Y?F$jRu)$>_+*=*Y?F$jRu)$>_+*=*Y?F$jRu) zNi#1i-{{DR2eS{8FQX5G_kZtyzJG)MMEy?xH9B%KI&v~Paxyw{GCFcHI&v~Paxyw{ zGCFcHI&v~Paxyw{GCFcHI&v~Paxyw{GCFc%&g#T6I&#AFLGXjYhxiY3KfM0n@o~#X zy-(XdjgFj*j+~5+oQ#f~jEc}{SPa))vC Ta_-`|&ptYGGCFcHTt-d+!Ii#a literal 0 HcmV?d00001 diff --git a/server/.gitignore b/server/.gitignore index 060dfba..bb3551c 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -3,3 +3,7 @@ build/ .eslintcache transpiled/ reports + +# Playwright e2e artifacts +e2e/test-results/ +e2e/playwright-report/ diff --git a/server/e2e/fixtures/coop.ts b/server/e2e/fixtures/coop.ts index b038f84..50a476a 100644 --- a/server/e2e/fixtures/coop.ts +++ b/server/e2e/fixtures/coop.ts @@ -1,10 +1,13 @@ // Load .env before anything reads process.env (the DI container does, heavily). import 'dotenv/config'; -import { test as base } from '@playwright/test'; +import { test as base, type APIRequestContext } from '@playwright/test'; import { uid } from 'uid'; import { type Dependencies } from '../../iocContainer/index.js'; +import { jsonStringify } from '../../utils/encoding.js'; + +export { jsonStringify }; /** * Server runtime is loaded from the COMPILED output (`transpiled/`), not the TS @@ -19,7 +22,14 @@ async function importIocContainer() { } async function importSeedHelpers() { - const [createOrg, ums, userPersistence] = await Promise.all([ + const [ + createOrg, + ums, + userPersistence, + createRule, + createMrtQueue, + itemSubmissionQueue, + ] = await Promise.all([ import(`${TRANSPILED}/test/fixtureHelpers/createOrg.js`) as Promise< typeof import('../../test/fixtureHelpers/createOrg.js') >, @@ -31,12 +41,24 @@ async function importSeedHelpers() { ) as Promise< typeof import('../../graphql/datasources/userKyselyPersistence.js') >, + import(`${TRANSPILED}/test/fixtureHelpers/createRule.js`) as Promise< + typeof import('../../test/fixtureHelpers/createRule.js') + >, + import(`${TRANSPILED}/test/fixtureHelpers/createMrtQueue.js`) as Promise< + typeof import('../../test/fixtureHelpers/createMrtQueue.js') + >, + import(`${TRANSPILED}/queues/itemSubmissionQueue.js`) as Promise< + typeof import('../../queues/itemSubmissionQueue.js') + >, ]); return { createOrg: createOrg.default, hashPassword: ums.hashPassword, UserRole: ums.UserRole, kyselyUserInsert: userPersistence.kyselyUserInsert, + createRule: createRule.default, + createMrtQueue: createMrtQueue.default, + ITEM_SUBMISSION_QUEUE_NAME: itemSubmissionQueue.ITEM_SUBMISSION_QUEUE_NAME, }; } @@ -49,6 +71,8 @@ export type SeededAdmin = { email: string; /** Plaintext password to log in with. */ password: string; + /** API key for the org's ingest endpoint (POST /api/v1/items/async). */ + apiKey: string; }; /** @@ -87,7 +111,144 @@ class Seeder { loginMethods: ['password'], }); - return { orgId: org.org.id, userId: user.id, email, password }; + return { + orgId: org.org.id, + userId: user.id, + email, + password, + apiKey: org.apiKey, + }; + } + + /** + * Create an MRT queue for the org. The first queue created for an org becomes + * the default queue (the destination for ENQUEUE_TO_MRT when no routing rule + * matches), so create exactly one queue before submitting if you rely on the + * default. The admin is assigned as a reviewer so the queue's jobs are + * visible to them via `reviewableQueues`. + */ + async createMrtQueue( + admin: SeededAdmin, + ): Promise<{ id: string; name: string }> { + const { createMrtQueue } = await importSeedHelpers(); + const { queue } = await createMrtQueue({ + orgId: admin.orgId, + mrtService: this.deps.ManualReviewToolService, + userId: admin.userId, + }); + return { id: queue.id, name: queue.name }; + } + + /** + * Create a LIVE content rule scoped to `itemTypeId` with the given + * `conditionSet` and `actionIds`. The conditionSet (what the rule matches) + * is owned by the caller; this factory just persists it via the + * `createRule` fixture helper. + */ + async createRule( + admin: SeededAdmin, + itemTypeId: string, + rule: { + conditionSet: unknown; + actionIds?: readonly string[]; + }, + ): Promise<{ id: string; name: string }> { + const { createRule } = await importSeedHelpers(); + const created = await createRule(this.deps.KyselyPg, admin.orgId, { + actionIds: rule.actionIds ?? [], + contentTypeIds: [itemTypeId], + conditionSet: rule.conditionSet as never, + }); + return { id: created.id, name: created.name }; + } + + /** + * Submit a content item via the real ingest endpoint (POST /api/v1/items/async), + * routed through the same origin the browser uses. The endpoint is async + * (202), so callers should waitForQueueDrained before reading the item. + */ + async submitContentItem( + request: APIRequestContext, + admin: SeededAdmin, + itemTypeId: string, + data: Record, + ): Promise<{ itemId: string }> { + const itemId = uid(); + const res = await request.post('/api/v1/items/async', { + headers: { 'x-api-key': admin.apiKey }, + data: { items: [{ id: itemId, typeId: itemTypeId, data }] }, + }); + if (res.status() !== 202) { + throw new Error( + `submitContentItem expected 202, got ${res.status()}: ${await res.text()}`, + ); + } + return { itemId }; + } + + /** + * Block until the item-submission BullMQ queue has no waiting or active jobs — + * i.e. every submitted item has been fully processed (written to Scylla, + * run through the rule engine, and any MRT jobs enqueued). Call this after + * `submitContentItem` and before navigating to a page that reads the + * processed item, so the read sees the data without the test having to + * poll the UI itself. + */ + async waitForQueueDrained(timeoutMs = 30_000): Promise { + const { ITEM_SUBMISSION_QUEUE_NAME } = await importSeedHelpers(); + const waitKey = `bull:${ITEM_SUBMISSION_QUEUE_NAME}:wait`; + const activeKey = `bull:${ITEM_SUBMISSION_QUEUE_NAME}:active`; + const redis = this.deps.IORedis; + const llen = async (key: string) => Number(await redis.llen(key)); + const deadline = Date.now() + timeoutMs; + while (true) { + if ((await llen(waitKey)) + (await llen(activeKey)) === 0) return; + if (Date.now() >= deadline) { + throw new Error('item-submission queue did not drain in time'); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + /** + * Authenticate as `admin` by calling the real `login` GraphQL mutation via + * `page.request`, which shares the page's cookie jar — so the session cookie + * the server sets lands on the browser automatically. Skips the UI login + * form, which other tests don't need to exercise (login.spec.ts does). After + * this, `page.goto('/dashboard/...')` works without re-authenticating. + */ + async login( + page: import('@playwright/test').Page, + admin: SeededAdmin, + ): Promise { + const res = await page.request.post('/api/v1/graphql', { + data: { + query: `mutation Login($input: LoginInput!) { + login(input: $input) { + __typename + ... on LoginSuccessResponse { user { id } } + ... on LoginUserDoesNotExistError { title } + ... on LoginIncorrectPasswordError { title } + } +}`, + variables: { input: { email: admin.email, password: admin.password } }, + }, + }); + if (!res.ok()) { + throw new Error( + `login mutation HTTP ${res.status()}: ${await res.text()}`, + ); + } + const body = (await res.json()) as { + data?: { login: { __typename: string } }; + errors?: unknown; + }; + const typename = body.data?.login.__typename; + if (typename !== 'LoginSuccessResponse') { + throw new Error( + `login failed: expected LoginSuccessResponse, got ${typename ?? 'no data'}`, + ); + } } } @@ -107,7 +268,26 @@ export const test = base.extend({ const { default: getBottle } = await importIocContainer(); const bottle = await getBottle(); const deps = bottle.container as Dependencies; + + // Start the item-processing worker inline so that content submitted via + // POST /api/v1/items/async gets drained from the Redis queue, run + // through the rule engine, and indexed — without a separate worker + // process. + const workerAbort = new AbortController(); + const workerRun = deps.ItemProcessingWorker.run(workerAbort.signal); + workerRun.catch((err) => { + console.error('ItemProcessingWorker exited with error', err); + }); + await use(deps); + + workerAbort.abort(); + try { + await deps.ItemProcessingWorker.shutdown(); + } catch { + // BullMQ's Worker.close() closes the shared ioredis connection, which + // can make closeSharedResourcesForShutdown throw on its own quit(). + } await deps.closeSharedResourcesForShutdown(); }, { scope: 'worker' }, diff --git a/server/e2e/fixtures/media.ts b/server/e2e/fixtures/media.ts new file mode 100644 index 0000000..f4fb0af --- /dev/null +++ b/server/e2e/fixtures/media.ts @@ -0,0 +1,11 @@ +/** + * Media URLs for e2e tests. The server's item-field validator only accepts + * http(s) URLs (no data URIs), and the audio must actually load to be played, + * so we serve a tiny fixture from the client dev server's `public/` dir + * (localhost is allowed via ALLOW_USER_INPUT_LOCALHOST_URIS=true). The image + * reuses the existing client logo. + */ + +export const IMAGE_URL = 'http://localhost:3000/logo192.png'; + +export const AUDIO_URL = 'http://localhost:3000/e2e/tone-3s.wav'; diff --git a/server/e2e/playwright.config.ts b/server/e2e/playwright.config.ts index be509c5..8ba67d8 100644 --- a/server/e2e/playwright.config.ts +++ b/server/e2e/playwright.config.ts @@ -10,9 +10,12 @@ export default defineConfig({ // Configured paths resolve relative to this config file's dir (server/e2e/), // so these land at server/e2e/{test-results,playwright-report}. outputDir: 'test-results', - // Run every test concurrently. This helps ensure that we do - // not get implicit dependencies between tests. - fullyParallel: true, + // Run the suite serially. The tests share a single in-process BullMQ worker + // (the e2e `deps` fixture) and a content-type fixture path that races a + // global `REFRESH MATERIALIZED VIEW` trigger under concurrent inserts — so + // parallel `createContentType` calls intermittently return undefined. + fullyParallel: false, + workers: 1, // Fail the build on CI if test.only was left in the source. forbidOnly: Boolean(process.env.CI), // Retry flaky flows on CI; fail fast locally. diff --git a/server/e2e/tests/investigation.spec.ts b/server/e2e/tests/investigation.spec.ts new file mode 100644 index 0000000..465ff34 --- /dev/null +++ b/server/e2e/tests/investigation.spec.ts @@ -0,0 +1,38 @@ +import { ScalarTypes, type Field } from '@roostorg/coop-types'; +import { uid } from 'uid'; + +import { expect, test } from '../fixtures/coop.js'; + +test('a submitted item can be found in the investigation tool', async ({ + page, + request, + deps, + seed, +}) => { + const admin = await seed.orgWithAdmin(); + const itemType = await deps.ModerationConfigService.createContentType( + admin.orgId, + { + name: `type-${uid()}`, + schema: [ + { + name: 'text', + type: ScalarTypes.STRING, + required: true, + container: null, + }, + ] as [Field, ...Field[]], + schemaFieldRoles: {}, + }, + ); + const { itemId } = await seed.submitContentItem(request, admin, itemType.id, { + text: 'hello from e2e', + }); + await seed.waitForQueueDrained(); + + await seed.login(page, admin); + await page.goto('/dashboard/manual_review/investigation'); + await page.getByPlaceholder('Enter an item ID').fill(itemId); + await page.getByRole('button', { name: 'Search' }).click(); + await expect(page.getByText(itemType.name).first()).toBeVisible(); +}); diff --git a/server/e2e/tests/item-signals.spec.ts b/server/e2e/tests/item-signals.spec.ts new file mode 100644 index 0000000..624558f --- /dev/null +++ b/server/e2e/tests/item-signals.spec.ts @@ -0,0 +1,60 @@ +import { ScalarTypes, type Field } from '@roostorg/coop-types'; +import { uid } from 'uid'; + +import { expect, jsonStringify, test } from '../fixtures/coop.js'; + +test('an item shows its rule execution / signal results in the investigation tool', async ({ + page, + request, + deps, + seed, +}) => { + const admin = await seed.orgWithAdmin(); + const itemType = await deps.ModerationConfigService.createContentType( + admin.orgId, + { + name: `type-${uid()}`, + schema: [ + { + name: 'text', + type: ScalarTypes.STRING, + required: true, + container: null, + }, + ] as [Field, ...Field[]], + schemaFieldRoles: {}, + }, + ); + const rule = await seed.createRule(admin, itemType.id, { + conditionSet: { + conjunction: 'AND', + conditions: [ + { + input: { + type: 'CONTENT_FIELD', + name: 'text', + contentTypeId: itemType.id, + }, + signal: { + id: jsonStringify({ type: 'TEXT_MATCHING_CONTAINS_TEXT' }), + type: 'TEXT_MATCHING_CONTAINS_TEXT', + }, + matchingValues: { strings: ['test'] }, + }, + ], + }, + }); + const { itemId } = await seed.submitContentItem(request, admin, itemType.id, { + text: 'this is a test', + }); + await seed.waitForQueueDrained(); + + await seed.login(page, admin); + await page.goto('/dashboard/manual_review/investigation'); + await page.getByPlaceholder('Enter an item ID').fill(itemId); + await page.getByRole('button', { name: 'Search' }).click(); + await expect(page.getByText(rule.name).first()).toBeVisible(); + await expect( + page.getByText('Matched', { exact: true }).first(), + ).toBeVisible(); +}); diff --git a/server/e2e/tests/item-type-creation.spec.ts b/server/e2e/tests/item-type-creation.spec.ts new file mode 100644 index 0000000..67a4dd7 --- /dev/null +++ b/server/e2e/tests/item-type-creation.spec.ts @@ -0,0 +1,48 @@ +import { uid } from 'uid'; + +import { expect, test } from '../fixtures/coop.js'; + +test('an admin creates an item type with mixed field types via the UI', async ({ + page, + seed, +}) => { + const admin = await seed.orgWithAdmin(); + await seed.login(page, admin); + + await page.goto('/dashboard/settings/item_types/form?kind=CONTENT'); + await expect(page.getByText('Create Item Type')).toBeVisible(); + + const typeName = `e2e-type-${uid()}`; + await page.locator('input[placeholder="Name"]').fill(typeName); + + const fields = [ + { name: 'text', type: 'String' }, + { name: 'image', type: 'Image' }, + { name: 'audio', type: 'Audio' }, + ]; + for (let i = 0; i < fields.length; i++) { + if (i > 0) { + await page.getByRole('button', { name: 'Add Field' }).click(); + } + await page + .locator('input[placeholder="Field Name"]') + .nth(i) + .fill(fields[i].name); + if (fields[i].type !== 'String') { + await page + .locator('.ant-select') + .nth(2 + i * 2) + .click(); + await page + .locator( + `.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option[title="${fields[i].type}"]`, + ) + .last() + .click(); + } + } + + await page.getByRole('button', { name: 'Create Content Type' }).click(); + await page.goto('/dashboard/settings/item_types'); + await expect(page.getByText(typeName)).toBeVisible(); +}); diff --git a/server/e2e/tests/login.spec.ts b/server/e2e/tests/login.spec.ts index acc5b9f..5373ee7 100644 --- a/server/e2e/tests/login.spec.ts +++ b/server/e2e/tests/login.spec.ts @@ -1,6 +1,9 @@ import { expect, test } from '../fixtures/coop.js'; -test('a user can log in', async ({ page, seed }) => { +test('a user can log in and their session persists until logout', async ({ + page, + seed, +}) => { const admin = await seed.orgWithAdmin(); await page.goto('/login'); @@ -9,4 +12,17 @@ test('a user can log in', async ({ page, seed }) => { await page.getByRole('button', { name: 'Sign In' }).click(); await expect(page).toHaveURL(/\/dashboard/); + await page.goto('/dashboard/overview'); + await expect(page).toHaveURL(/\/dashboard\/overview/); + + await page.reload(); + await expect(page).toHaveURL(/\/dashboard\/overview/); + + const res = await page.request.post('/api/v1/graphql', { + data: { query: 'mutation { logout }' }, + }); + expect(res.ok()).toBeTruthy(); + + await page.goto('/dashboard/overview'); + await expect(page).toHaveURL(/\/login/); }); diff --git a/server/e2e/tests/mrt-job-review.spec.ts b/server/e2e/tests/mrt-job-review.spec.ts new file mode 100644 index 0000000..c8a3aa0 --- /dev/null +++ b/server/e2e/tests/mrt-job-review.spec.ts @@ -0,0 +1,111 @@ +import { ScalarTypes, type Field } from '@roostorg/coop-types'; +import { uid } from 'uid'; + +import { expect, jsonStringify, test } from '../fixtures/coop.js'; +import { AUDIO_URL, IMAGE_URL } from '../fixtures/media.js'; + +// VIDEO is intentionally omitted — react-player/lazy (used by +// ManualReviewJobContentBlurableVideo) crashes in vite dev mode ("Element type +// is invalid: lazy element must resolve to a class or function"), taking down +// the whole page (no per-field error boundary). Re-add VIDEO once that is fixed. +const FIELDS: Field[] = [ + { name: 'text', type: ScalarTypes.STRING, required: true, container: null }, + { name: 'image', type: ScalarTypes.IMAGE, required: false, container: null }, + { name: 'audio', type: ScalarTypes.AUDIO, required: false, container: null }, +]; + +test('an MRT job renders text/image/audio, plays audio, and records a decision', async ({ + page, + request, + deps, + seed, +}) => { + const admin = await seed.orgWithAdmin(); + const itemType = await deps.ModerationConfigService.createContentType( + admin.orgId, + { + name: `type-${uid()}`, + schema: FIELDS as [Field, ...Field[]], + schemaFieldRoles: {}, + }, + ); + const queue = await seed.createMrtQueue(admin); + const actions = await deps.ModerationConfigService.getActions({ + orgId: admin.orgId, + }); + const enqueueToMrt = actions.find((a) => a.actionType === 'ENQUEUE_TO_MRT'); + if (enqueueToMrt == null) { + throw new Error('ENQUEUE_TO_MRT built-in action not found for org'); + } + await seed.createRule(admin, itemType.id, { + actionIds: [enqueueToMrt.id], + conditionSet: { + conjunction: 'AND', + conditions: [ + { + input: { + type: 'CONTENT_FIELD', + name: 'text', + contentTypeId: itemType.id, + }, + signal: { + id: jsonStringify({ type: 'TEXT_MATCHING_CONTAINS_TEXT' }), + type: 'TEXT_MATCHING_CONTAINS_TEXT', + }, + matchingValues: { strings: ['test'] }, + }, + ], + }, + }); + + const uniqueText = `test media ${uid()}`; + await seed.submitContentItem(request, admin, itemType.id, { + text: uniqueText, + image: IMAGE_URL, + audio: AUDIO_URL, + }); + await seed.waitForQueueDrained(); + + await seed.login(page, admin); + await page.goto(`/dashboard/manual_review/queues/review/${queue.id}`); + await expect(page).toHaveURL(/\/review\/[^/]+\/[^/]+\/[^/]+/); + const jobId = new URL(page.url()).pathname.split('/').at(-2)!; + + await expect(page.getByText('Text', { exact: true })).toBeVisible(); + await expect(page.getByText('Image', { exact: true })).toBeVisible(); + await expect(page.getByText('Audio', { exact: true })).toBeVisible(); + + const audio = page.locator('audio').first(); + await expect(audio).toBeVisible(); + await audio.evaluate(async (el: HTMLAudioElement) => { + // eslint-disable-next-line functional/immutable-data -- DOM elements are mutable by nature. + el.muted = true; + await el.play(); + }); + + const submitResponse = page.waitForResponse( + (resp) => + resp.url().includes('/api/v1/graphql') && + resp.request().postData()?.includes('submitManualReviewDecision') === + true, + ); + await page + .getByTestId('manual-review-decision-action-list') + .getByText('Ignore', { exact: true }) + .click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await submitResponse; + + const res = await page.request.post('/api/v1/graphql', { + data: { + query: `query GetDecidedJobFromJobId($id: String!) { + getDecidedJobFromJobId(id: $id) { decision { id } } +}`, + variables: { id: jobId }, + }, + }); + const body = (await res.json()) as { + data?: { getDecidedJobFromJobId?: { decision?: { id: string } } }; + }; + expect(body.data?.getDecidedJobFromJobId?.decision).not.toBeNull(); +}); diff --git a/server/e2e/tests/rule-creation.spec.ts b/server/e2e/tests/rule-creation.spec.ts new file mode 100644 index 0000000..da746f6 --- /dev/null +++ b/server/e2e/tests/rule-creation.spec.ts @@ -0,0 +1,72 @@ +import { ScalarTypes, type Field } from '@roostorg/coop-types'; +import { uid } from 'uid'; + +import { expect, test } from '../fixtures/coop.js'; + +test('an admin creates a content rule with a condition and an MRT action via the UI', async ({ + page, + deps, + seed, +}) => { + const admin = await seed.orgWithAdmin(); + const itemType = await deps.ModerationConfigService.createContentType( + admin.orgId, + { + name: `e2e-type-${uid()}`, + schema: [ + { + name: 'text', + type: ScalarTypes.STRING, + required: true, + container: null, + }, + ] as [Field, ...Field[]], + schemaFieldRoles: {}, + }, + ); + + await seed.login(page, admin); + await page.goto('/dashboard/rules/proactive/form'); + await expect(page.getByText('Create Rule').first()).toBeVisible(); + + const ruleName = `e2e-rule-${uid()}`; + await page.locator('input').first().fill(ruleName); + await page.locator('.ant-select').first().click(); + await page + .locator( + `.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option[title="${itemType.name}"]`, + ) + .click(); + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.locator('.ant-select').nth(1).click(); + await page + .locator( + '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option', + ) + .filter({ hasText: /^text$/ }) + .last() + .click(); + await page.getByRole('button', { name: 'Select Signal' }).click(); + await page.getByPlaceholder('Search').fill('Contains text'); + await page.getByText('Contains text', { exact: true }).click(); + await page.getByPlaceholder('Input Strings').fill('test'); + await page.getByPlaceholder('Input Strings').press('Enter'); + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.locator('.ant-select').nth(2).click(); + await page + .locator( + '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option', + ) + .filter({ hasText: 'Enqueue Item to Manual Review' }) + .last() + .click(); + await page.getByText('Live', { exact: true }).click(); + await page.getByRole('button', { name: 'Create Rule' }).click(); + + await expect(page.getByText('Rule Created')).toBeVisible(); + await page.getByRole('button', { name: 'OK' }).click(); + await expect(page).toHaveURL(/\/dashboard\/rules\/proactive/); + await expect(page.getByText(ruleName)).toBeVisible(); +}); diff --git a/server/e2e/tests/rule-routing.spec.ts b/server/e2e/tests/rule-routing.spec.ts new file mode 100644 index 0000000..e21e68e --- /dev/null +++ b/server/e2e/tests/rule-routing.spec.ts @@ -0,0 +1,67 @@ +import { ScalarTypes, type Field } from '@roostorg/coop-types'; +import { uid } from 'uid'; + +import { expect, jsonStringify, test } from '../fixtures/coop.js'; + +test('a rule routes a submitted item into a manual review queue', async ({ + page, + request, + deps, + seed, +}) => { + const admin = await seed.orgWithAdmin(); + const itemType = await deps.ModerationConfigService.createContentType( + admin.orgId, + { + name: `type-${uid()}`, + schema: [ + { + name: 'text', + type: ScalarTypes.STRING, + required: true, + container: null, + }, + ] as [Field, ...Field[]], + schemaFieldRoles: {}, + }, + ); + const queue = await seed.createMrtQueue(admin); + const actions = await deps.ModerationConfigService.getActions({ + orgId: admin.orgId, + }); + const enqueueToMrt = actions.find((a) => a.actionType === 'ENQUEUE_TO_MRT'); + if (enqueueToMrt == null) { + throw new Error('ENQUEUE_TO_MRT built-in action not found for org'); + } + await seed.createRule(admin, itemType.id, { + actionIds: [enqueueToMrt.id], + conditionSet: { + conjunction: 'AND', + conditions: [ + { + input: { + type: 'CONTENT_FIELD', + name: 'text', + contentTypeId: itemType.id, + }, + signal: { + id: jsonStringify({ type: 'TEXT_MATCHING_CONTAINS_TEXT' }), + type: 'TEXT_MATCHING_CONTAINS_TEXT', + }, + matchingValues: { strings: ['test'] }, + }, + ], + }, + }); + + const uniqueText = `test-${uid()}`; + await seed.submitContentItem(request, admin, itemType.id, { + text: uniqueText, + }); + await seed.waitForQueueDrained(); + + await seed.login(page, admin); + await page.goto(`/dashboard/manual_review/queues/jobs/${queue.id}`); + await expect(page.getByText(`Jobs in ${queue.name}`)).toBeVisible(); + await expect(page.getByText(uniqueText)).toBeVisible(); +}); diff --git a/server/utils/encoding.ts b/server/utils/encoding.ts index 2414c1e..fef3184 100644 --- a/server/utils/encoding.ts +++ b/server/utils/encoding.ts @@ -1,7 +1,7 @@ import stringify from 'safe-stable-stringify'; import { type Opaque } from 'type-fest'; -import { JSON } from './json-schema-types.js'; +import { type JSON } from './json-schema-types.js'; /** * This function accepts any JS string and encodes it in base64, using a UTF8 -- 2.51.2