Compare commits

...
Author SHA1 Message Date
Aaro Koivukangas d66fea3d7b [E2E] Fix test infobox wording 2026-10-12 15:31:34 +03:00
joonas-a dcfac28fa6 [Progress] combined programme criteria fix
thanks claude opus 5.5 high
2026-10-12 15:20:23 +03:00
joonas-a 09b743fb9b [Degree programme settings] separate criteria from visibility 2026-10-12 15:20:23 +03:00
joonas-a 36159c7305 [Progress tab] refactoring
- many logic fixes
- new: display also course code in addition to name
- better naming for columns
2026-10-12 15:20:23 +03:00
Aaro Koivukangas 1c84948f3c [ok.sh] Add more aliases for npm run both:real 2026-10-12 12:43:04 +03:00
joonas-a 631b652553 [Progress tab] Show when credit criteria is set, even when no course criteria 2026-10-07 11:15:37 +03:00
joonas-a 41d027e054 [Monthly credit accml] fix crash caused by date change mid-computation
uses months to compute keys rather than dates
2026-10-07 11:07:50 +03:00
joonas-a caa7739f37 [Ooditable] allow toolbar to optinally receive table state 2026-10-06 12:00:58 +03:00
joonas-a be91975591 [Users] bring back email copy button 2026-10-06 11:53:44 +03:00
joonas-a f62984143a [Users] extract component to own file 2026-10-06 11:36:37 +03:00
joonas-a 1645f568f8 [Users] Show loading component when waiting for data 2026-10-06 11:32:51 +03:00
Santeri Rajamäkiandjoonas-a 15dac7c158 [Frontend][Users] MRT to OT 2026-10-06 11:14:27 +03:00
joonas-a edc6182b20 [User page] layout fix 2026-10-06 10:44:08 +03:00
joonas-a 183a4cd782 [Frontend] fix singular/plural student(s) 2026-10-06 10:26:35 +03:00
Aaro Koivukangas c3f776c9ea [Vite] Add vite cache dir to frontend to fix permission errors 2026-10-06 09:06:33 +03:00
Aaro Koivukangas 5f96b20936 [ok.sh] Add option to append flags to commands 2026-10-06 09:06:33 +03:00
Aaro Koivukangas 50e37c8ef6 [Dockerfile] Remove unnecessary flags from npm ci 2026-10-06 09:06:33 +03:00
Aaro Koivukangas 8287a4a37f [Docker] Remove local .npm cache files from containers 2026-10-06 09:06:33 +03:00
joonas-a 1e46a27712 [Unit tests] test course visibility alert feature 2026-10-05 15:59:40 +03:00
joonas-a fe72648a77 [Modules table] show hidden modules if any 2026-10-05 15:59:40 +03:00
joonas-a 7c6fb10521 [Courses of class] add hidden course indicator to table 2026-10-05 15:59:40 +03:00
joonas-a f394a1515f [Frontend] margin consistency fix 2026-10-05 15:59:40 +03:00
joonas-a f0cf3a12a5 [Frontend][Courses tab] display when courses are hidden in table 2026-10-05 15:59:40 +03:00
Aaro Koivukangas 9e53a739f6 [ok.sh] Add more scripts to ok 2026-09-30 15:44:01 +03:00
Aaro Koivukangas 74e3e59cd8 [Tests][Updater] Fix tests failing when tests in dist run 2026-09-30 15:44:01 +03:00
joonas-a 8a14efa403 [Test] test 2026-09-30 15:42:05 +03:00
joonas-a a77c70150c [Custom pop course export] fix code/name incorrect in exported file 2026-09-30 15:41:58 +03:00
joonas-a 89dd5f1322 [Tests] Test course tab export 2026-09-30 15:29:20 +03:00
joonas-a 75d4c78adb [Courses tab] Fix export using incorrect column keys 2026-09-30 15:29:20 +03:00
Aaro Koivukangas 25bdf0cf8f [Scripts] Add dump cleaner so that images don't accumulate over time too much 2026-09-30 13:58:30 +03:00
joonas-a ca16291e37 [Kone] Migration step 2 of 2
- Remove old fields from db
- Not null enforced
- Remove dead code
2026-09-29 17:05:31 +03:00
joonas-a 1ced256f43 [Tests] add missing field due to type changes 2026-09-29 16:14:10 +03:00
joonas-a a00fb4b78e [Courses tab] Move away from idToGroupId map
more work to do still
2026-09-29 16:09:20 +03:00
joonas-a 874c8c6d00 [Class stats][RTKapi] drop unneeded data from response 2026-09-29 15:08:45 +03:00
joonas-a fef7faa8e7 [Backend][statisticsOf] add course groupId to student credits 2026-09-29 14:58:21 +03:00
joonas-a fa21354b8b [Hooks] make use of curriculum context 2026-09-29 13:51:15 +03:00
joonas-a ce2a035111 [SGG] better infobox regarding the curricula selector 2026-09-29 13:28:19 +03:00
joonas-a 1a48b7a028 [Hooks] Add curriculum context
use to avoid excess prop drilling and desynced state between the view
and editor modal
2026-09-29 13:21:10 +03:00
joonas-a a8fe237e3d [Kone] Progress criteria migration to group id step 1 2026-09-29 12:38:40 +03:00
joonas-a fb3ca8b699 [Kone] Course exclusion migration to group id step 1 2026-09-29 12:18:34 +03:00
joonas-a 8d7966b93f [Scripts] add kone-db to ok.sh 2026-09-29 11:50:51 +03:00
Aaro Koivukangas ed631a4d3b [GithubActions] Install s3cmd in each db step 2026-09-28 15:43:15 +03:00
Aaro Koivukangas 2459f73f56 [GithubActions] Enable db scripts 2026-09-28 14:00:24 +03:00
Aaro Koivukangas 1ac138a494 [Scripts] Greatly improve db scripts 2026-09-28 13:57:35 +03:00
Aaro Koivukangas 351310c8d6 [Format] Format fixes 2026-09-24 16:05:43 +03:00
joonas-a 484dbdbe40 [E2E] fix old infobox text test 2026-09-22 16:49:32 +03:00
joonas-a a85b886167 [Course criterion table] bättre användbarhet och layout 2026-09-22 16:14:45 +03:00
joonas-a 6f404658d1 [Class stats] manage visibility button add to all relevant places, tweak warning 2026-09-22 15:50:46 +03:00
joonas-a e0e47554a5 [Courses tab] add button to open course visibility modal 2026-09-22 15:24:02 +03:00
joonas-a 3ae0cb0249 [Courses shown] rewording + more emphasis on warning 2026-09-22 15:23:42 +03:00
joonas-a 30d8b03485 [Courses tab] Do not show duplicate courses under same module in rare instances 2026-09-22 14:05:35 +03:00
joonas-a 0126c33ff8 [TSX] Toggle substitution switch 2026-09-22 13:52:27 +03:00
joonas-a 0ee5b514f6 [Ooditable] reusable "copy studentnumbers" button
+ add to progress tab
2026-09-22 13:45:22 +03:00
joonas-a 7a8e258b7c [Progress] Make infobox viewport-width-efficient
and tsx
2026-09-22 13:31:30 +03:00
joonas-a b5d3fabe71 [Infobox] i18n and rephrasing 2026-09-22 12:39:12 +03:00
joonas-a 5e25423230 [Degree programme settings] Clarify modal and more infobox 2026-09-22 12:33:09 +03:00
Aaro Koivukangas b6c06c663a [ComponentTests] Add check that Progress tab is not lost 2026-09-22 11:56:08 +03:00
joonas-a 81bce184a4 [Progress tab] fix tab never shown 2026-09-22 11:32:13 +03:00
Aaro Koivukangas 1b45a089b0 [Backend] Add older doctoral programmes back to "Update meta" 2026-09-22 11:29:46 +03:00
joonas-a a1560898ad [Manage courses shown] Clarify in modal what it does 2026-09-22 11:15:16 +03:00
outisa fd5fe1189c [Degree programmes] Fix sort by valid from 2026-09-22 10:04:08 +03:00
outisa 9e98fc1a6a [Updater meta] Update relevant phd programmes 2026-09-22 10:03:03 +03:00
outisa 7eb7d82088 Update programme codes 2026-09-22 09:41:29 +03:00
Aaro Koivukangas 83cd46f565 [Navbar] Change Courses to Courses / Modules 2026-09-22 09:39:50 +03:00
Aaro Koivukangas f266389f32 [GithubActions] Disable running db update, doesn't work yet 2026-09-21 15:59:26 +03:00
Aaro KoivukangasandAaro Koivukangas fc2ad0f519 [Meta] Small fixes to variable names etc. 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 7e1986e40a [GithubActions] Rename e2e test names 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 9ac859861d [Course search] Disable special course switch temporarily 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas c04b2d0609 [Tests] Fix broken tests by skipping and updating numbers 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas d72a065b2d [Docker] Add adminer as a possible profile to docker ci 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas e0a5c872d4 [E2E][LanguageCenter] Skip flaky test + try to make data deterministic 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas d3b8eb6fa8 [E2E][NumbersMatch] Change to use course_ids 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas a8d7d90ef4 [Updater] Change error to warning with missing teacher credit_id 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 9f2b378586 [UnitTests][Updater] Change courseProvider mapper share to be a reasonable value 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas facac78e8c [E2E][LanguageCenterView] Try to make data more deterministic 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 81768930af [AI] Disallow also psql 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas c869520020 [ComponentTests] Fix broken tests 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 34dc354165 [E2E][Filters] Fix tests 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 60644df366 [DockerCompose] Add env vars to change the user the containers run as to fix permission errors 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 76a9dfb1d3 [E2E][CourseStatistics] Fix tests depending on courseCodes 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas be73373da0 [CourseStats][Summary] Display courseCode instead of groupId 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 3d35543136 [CourseStatistics] Don't crash with null substitution group 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 428ba97691 [ApiTests] Fix Programme courses count 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 0e7824b898 [GithubActions] Add updating databases back to actions 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 16909943b6 [Scripts] Add non-interactive mode to db_utils 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas ef76f3d664 [Adminer] Update adminer version to fix permission error 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 812e3de65d [Updater] Don't throw on credit with incorrect course_id, just log 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 93538f3c11 [Updater][Scheduler] Fix error.message failing, error undefined 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 0a301e2cb7 [Meta] Fix broken import in shared 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 172081468a [Meta] Add course code to credits and enrollments of students 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 6d169a3455 [Lint] Fix lint errors 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 172d4df46d [Filters][Grade] Fix test 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas f0d802df83 [Filters][Grade] Remove incorrect filtering of students without a grade 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 86900b3ad4 [UnitTests] Update course matrix to use courseIds 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas ca54fba5be [GithubActions][Updater] Add updater unit tests to github actions 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas f5229ee678 [UnitTests][Updater] Add basic unit tests to updater 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 3a1bee9b79 [ApiTests] Remove commented logs 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas b9d6d9f12a [UnitTests][Filters][Grade] Fix broken filter 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas cf4751b3f3 [UnitTests][Filters][Hops] Change to use course_id instead of course_code 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas a656667fa3 [UnitTests][Filter] Change to use course_id instead of course_code 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 7b772e188a [Tests][Backend] Fix remaining tests and use better courseCodeToGroupId format 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 793bf317e8 [ServiceTests] Fix tests to handle CU changes 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 7aa3eaa825 [Open beta][Scripts] Publish ok.sh rev2.0 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 8ab35e2c21 [Irtomikko] Make it more rare 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 1785b6320c [Course rework] bugfixes from inconsistent code/groupId/id usage 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 251edb0fbb [Lint] Fix lint errors 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 811b3e2476 [ApiTests][ProgrammeCourses] Fix tests to handle CU changes 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas c280bdf482 [ApiTests][Population] Fix tests to handle CU changes 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas cc4ca02b7a [ApiTests][CourseYearlyStats] Fix tests to handle CU changes 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas b63c3703e8 [Lang center] group results correctly 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas cdac1218af [SGG] Margin to error message 2026-09-21 15:24:21 +03:00
Aaro KoivukangasandAaro Koivukangas 11bda04e56 [CourseStats] Remove credittypecode filtering 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas cb57167828 [Programme overview][Basic stats] Correctly fetch courses for the provider with ids 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas d917b103fc [Programme courses][By semester] Fix total col showing all zeros 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas c922ded91f [Programme courses][By semester] Get attainments by id/groupId 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas eabe1406be [Course rework] finishing touches, mainly programme courses 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 359e23ef84 [Updater][bulkCreate] acually attempt to upsert every entity in failing batch 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 3919260b2a [Routing] Update all links to courestatistics 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 76311ced78 [General tab] fix studyplan dependant columns 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas b09d9c204d [CreditMapper] attained modules: groupId -> id 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 7af9d19f04 [Studyplans] embrace course unit id 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 756338d15f [Updater][Studyplans] use ids instead of course codes 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas bd69a8318e [Course rework][Class stats] More migration to groupIds
Incl. courses tab
2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas cb0b35c73d [Course filter] Show only relevant substitutions
dont show substitutions that no student in the population has (we have
no code/name for that, as we only fetch relevant entities off backend)
2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas e69e1d2f16 [FilteredCourses][Populations] Use groupId
still wip
2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas d3b3d82fee [Student details] add groupIds to coursestats links 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 1915757425 [Course population][Course rework] Use ids and groupIds in favor of volatile course codes
WIP: Course filter needs work and studyplans are still broken
2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 5aa0a188c9 [Grade filter] Fix "no grade"s and use course id 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 97d135f82f [Class stats] fix issue reading query params 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 7384a884fb [git] fix rebase issue 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 11506ea22e [Backend] remove redundancy
no need for b2b queries, the studentnumber query is fast already even
with huge populations
2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 8c9f1e119f [Course refactor][Backend] course population endpoint fixes 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 2c0fd4cb85 [Course refactor] re-enable max population limit 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas b7934d642b [Course refactor] Course stats work
Moving from using only codes to working with group_ids
2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 9c40be64e8 [Language hook] allow string as input
Streamlines many Name | string types
2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 96f8b43732 [WIP][Course refactor] backup working tree 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas c908a61c0f [Backend][Course search] make course search use new course units and substitutions 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas 5bd7fb6792 [Updater] Add migration, update models 2026-09-21 15:24:21 +03:00
joonas-aandAaro Koivukangas b8e9d59217 [Updater] Rework course mapper
Add groupId and validityPeriod, create entries for all courseUnits for a
group rather than just one
2026-09-21 15:24:21 +03:00
outisa ae4b0b3d52 Update slackbot-action version [skip ci] 2026-09-16 15:15:16 +03:00
outisa 280fc09a9a Change use WebHook url with old name [skip ci] 2026-09-14 21:20:38 +03:00
outisa a6ff2f63f7 Change filter text for old and specialized programmes 2026-09-14 20:50:54 +03:00
Outi SavolainenandGitHub 465ce35ca0 Change webhook url [skip ci] 2026-09-14 20:48:25 +03:00
Aaro Koivukangas c24f2c65b0 [Filters][Grade] Acually fix the error 2026-09-01 12:25:45 +03:00
Aaro Koivukangas f8f5c4ba32 [Filters][Grade] Fix No grade option not working 2026-09-01 11:42:55 +03:00
outisa cc1630f982 Remove unnecessary " from a master programme 2026-08-27 13:25:29 +03:00
outisa 1a6b89c8f1 Remove extra " from programme id 2026-08-27 10:34:24 +03:00
Aaro Koivukangas b84c2c1e9e [E2E][CourseStats] Fix test numbers 2026-08-24 21:48:56 +03:00
Aaro Koivukangas e99119a868 [AI] Add suggestion to ask about db 2026-08-24 17:09:46 +03:00
Aaro Koivukangas d1c3baeac4 [CourseStats][Attempts] Count attempts properly finally 2026-08-24 17:09:21 +03:00
Aaro Koivukangas fa0f88b9af [ApiTests][CourseYearlyStats] Add tests for attempts 2026-08-24 16:25:19 +03:00
Aaro Koivukangas 9a53ec4b56 [ApiTests][CourseYearlyStats] Move student tests to new directory 2026-08-24 16:24:58 +03:00
Aaro Koivukangas a328268b53 [AI] Remove mention of cypress 2026-08-21 18:31:30 +03:00
Aaro Koivukangas b60b5ac804 [E2E][CourseStats] Update e2e tests to match updated data 2026-08-21 18:31:09 +03:00
Aaro Koivukangas b540419ea6 [ApiTests] Split the one massive courseyearlystats test file into multiple smaller ones 2026-08-21 18:07:22 +03:00
Aaro Koivukangas d41acb17d8 [UnitTests] Don't use backend type in shared 2026-08-21 17:54:57 +03:00
Aaro Koivukangas 66b1a40649 [CourseStats] Change to use yearCodeToYear util 2026-08-21 17:47:14 +03:00
Aaro Koivukangas 9364d7c7ca [CourseStatistics] Fix attempt calculations 2026-08-21 17:47:14 +03:00
Aaro Koivukangas 8a867f0b4c [UnitTests][parseCredit] Add parseCredit tests and refactor helpers to have correct types 2026-08-21 17:47:14 +03:00
Aaro Koivukangas 1b8eb0dbf5 [UnitTests] Move helpers to shared and change paths to use @ in backend tests 2026-08-21 17:47:14 +03:00
Aaro Koivukangas 9202c5d32c [UnitTests][CourseStatistics] Add unit tests with sequelize mocking to test getCreditsForCourses 2026-08-21 17:47:13 +03:00
outisa 9a779061cb Remove extra zeros from programme code 2026-08-21 17:45:02 +03:00
outisa dd25ee378b Add abbreviation for MS programme in Athmospheric Sciences 2026-08-21 16:59:37 +03:00
outisa 03efe1e609 Update programmeCodes list with missing programmes 2026-08-21 15:52:36 +03:00
Aaro Koivukangas 504b88a779 [Faculty] Add older doctoral programmes back to facultystats 2026-08-21 08:21:24 +03:00
Aaro Koivukangas 799674e763 [ApiTests] Add courseyearlytest for upcoming changes 2026-08-20 18:54:15 +03:00
Aaro Koivukangas 5de455da20 [E2E] Fix broken e2e test 2026-08-20 12:48:03 +03:00
Aaro Koivukangas 00feec055c [CourseStatistics] Change the location where barchart direction is done, previous breaks attempts 2026-08-20 12:47:48 +03:00
Aaro Koivukangas bf54702cd7 [CourseStats] Fix barchart years direction 2026-08-20 12:18:32 +03:00
Aaro Koivukangas b710a6b671 [GithubActions] Add 15 minute timeout to all actions to stop hanging 2026-08-20 10:53:33 +03:00
Aaro Koivukangas 2dd17ca6c8 [AI] Update agents.md to include a ban from accessing the db + claude settings to enforce that 2026-08-20 10:42:16 +03:00
Aaro Koivukangas a316476877 [UnitTests] Add transferredToProgrammeFilter tests 2026-08-20 10:06:46 +03:00
Aaro Koivukangas 53c33e631e [UnitTests] Add tagsFilter tests 2026-08-20 10:04:35 +03:00
Aaro Koivukangas d904afd07c [UnitTests] Add studyTrackFilter tests 2026-08-20 10:01:10 +03:00
Aaro Koivukangas bf6a4837df [UnitTests] Add studyRightTypeFilter tests 2026-08-20 09:58:36 +03:00
Aaro Koivukangas 0a0651d61a [UnitTests] Add studyRightStatusFilter tests 2026-08-20 09:45:15 +03:00
Aaro Koivukangas 7df8a26448 [UnitTests] Add studentNumberFilter tests 2026-08-20 09:36:37 +03:00
Aaro Koivukangas a5b2293ea8 [UnitTests] Add startYearAtUniFilter tests 2026-08-20 09:32:21 +03:00
Aaro Koivukangas 8ecbe5d4dc [UnitTests] Add programmeFilter tests 2026-08-20 09:30:27 +03:00
Aaro Koivukangas a4135cbace [UnitTests] Add hetuFilter tests 2026-08-19 20:24:51 +03:00
Aaro Koivukangas b362c7bf81 [UnitTests] Add graduatedFromProgrammeFilter tests 2026-08-19 20:23:18 +03:00
Aaro Koivukangas 0dfaf66274 [UnitTests] Add gradeFilter tests 2026-08-19 20:14:24 +03:00
Aaro Koivukangas faa18693d3 [UnitTests] Add genderFilter tests 2026-08-19 20:09:03 +03:00
Aaro Koivukangas 57d4ad661b [UnitTests] Add enrolmentStatusFilter tests 2026-08-19 20:03:34 +03:00
Aaro Koivukangas 81996a0130 [UnitTests] Add dateFilter tests 2026-08-19 19:58:15 +03:00
Aaro Koivukangas 70e13b1722 [UnitTests] Add curriculumPeriodFilter tests 2026-08-19 19:58:15 +03:00
Aaro Koivukangas 5e913a911b [UnitTests] Add creditsEarnedFilter tests 2026-08-19 19:58:10 +03:00
Aaro Koivukangas 76d19e4411 [UnitTests] Add courseFilter tests 2026-08-19 19:46:32 +03:00
Aaro Koivukangas 6249612e7a [UnitTests] Add citizenshipFilter tests 2026-08-19 19:40:03 +03:00
Aaro Koivukangas 5c2f477794 [UnitTests] Add a mock to window.localStorage so that filter tests don't fail if using useLanguage 2026-08-19 19:39:05 +03:00
Aaro Koivukangas 1430923ec4 [UnitTests] Add ageFilter tests 2026-08-19 19:29:35 +03:00
Aaro Koivukangas 06fda57dae [UnitTests] Add admissionTypeFilter tests 2026-08-19 19:26:28 +03:00
Aaro Koivukangas 8e8a99fe10 [UnitTests] Move multiple factories to helper file 2026-08-19 19:05:11 +03:00
Aaro Koivukangas d069d9675e [UnitTests][Filters][Hops] Add hops filter unit tests 2026-08-19 18:27:26 +03:00
Aaro Koivukangas 36631faf4d [Filters][Hops] Fix study right logic when student has a cancelled study right 2026-08-19 17:24:39 +03:00
Aaro Koivukangas 13b3996a11 [GithubActions] Remove unused configurations 2026-08-19 14:16:18 +03:00
Aaro Koivukangas e91523b268 [Scripts] Update db script to it's final form 2026-08-19 14:16:00 +03:00
Aaro Koivukangas 230b9fd076 [Scripts] Add menus to db script 2026-08-19 12:15:58 +03:00
Aaro Koivukangas e17465150b [E2E] Fix broken e2e test 2026-08-19 10:50:02 +03:00
Aaro Koivukangas c75202c1ef [DoctoralProgrames] Change credit limit to 30cr 2026-08-19 10:07:47 +03:00
Aaro Koivukangas 50f9fe29e1 [DoctoralProgrammes] Count DP*** as new programmes 2026-08-19 10:06:13 +03:00
Aaro Koivukangas c16ad83477 [Tests] Add api and unit tests for Custom population related features 2026-08-18 10:26:06 +03:00
Aaro Koivukangas 2449dc521f [CustomPopulation] Added option to make population with programmes 2026-08-18 10:23:12 +03:00
Aaro Koivukangas 7d3b7b8227 [Frontend][UnitTests] Migrate frontend unit tests to vitest 2026-08-14 14:03:21 +03:00
Aaro Koivukangas 1da147503b [Winston] Remove winston-gelf-transporter override from package.jsons 2026-08-14 13:47:33 +03:00
Aaro Koivukangas cf003fd593 [Winston] Remove loggers to old server that will be deleted 2026-08-14 13:45:09 +03:00
Aaro Koivukangas 0456b32bf2 [Scripts] Remove jami from logs 2026-08-14 13:14:49 +03:00
Aaro Koivukangas 96d058d877 [UnitTests][CriteriaProgress] Add tests for getProgressCriteria 2026-08-14 13:14:49 +03:00
Aaro Koivukangas d2788a13d8 [CriteriaProgress][Substitutions] Remove the last instance of substitutions!!!!! 2026-08-14 13:14:49 +03:00
Aaro Koivukangas a3244ca95c [Precommit] Format lintstaged file 2026-08-13 09:33:16 +03:00
Aaro Koivukangas 5dea5e1cb8 [Banners] Fix not being able to delete the last character 2026-08-11 11:47:25 +03:00
Aaro Koivukangas c81e5ee1e5 [GithubActions] Trigger staging + pull requests when lint rules are changed 2026-08-11 10:30:03 +03:00
Aaro Koivukangas abec1552d0 [Backend][DB] Add sensible values to db connections + use var DB_MAX_CONNECTIONS 2026-08-10 16:17:23 +03:00
Aaro Koivukangas 53ef3c1edc [Lint] Add lint rule also for Date.now() 2026-08-10 15:22:08 +03:00
joonas-a d23c54fd27 [Backend] add logging to failing function 2026-08-10 15:18:45 +03:00
Aaro Koivukangas 418f039d92 [GithubActions][E2E] Trigger actions if e2e has changed 2026-08-10 15:02:02 +03:00
Aaro Koivukangas a0383aaae8 [E2E] Fix custom population test 2026-08-10 14:59:48 +03:00
Aaro Koivukangas 6030176457 [GithubActions] Remove docker image caching because layers now fixed 2026-08-10 13:52:15 +03:00
Aaro Koivukangas 5b36c162a4 [Scripts] Add db script to handle db update 2026-08-10 13:35:02 +03:00
Aaro Koivukangas ec8c426056 [Docker] Add a docker compose file to easily build all new test dbs 2026-08-10 10:22:00 +03:00
Aaro Koivukangas f22b16295c [Docker][DBs] Add a dockerfile to update test dbs (without adding layers) 2026-08-10 10:20:48 +03:00
Aaro Koivukangas 0b0f52ba3f [Vibe] Add a compact AGENTS.md 2026-08-10 10:18:44 +03:00
Aaro Koivukangas f7d6c6f5a7 [Readme] Update readme to match new e2e changes 2026-08-10 10:18:01 +03:00
Aaro Koivukangas 6a4c099024 [Prettier] Remove prettier, using oxfmt 2026-08-10 10:17:38 +03:00
Aaro Koivukangas 18cc8cef05 [Meta] Remove unused .vscode directory 2026-08-10 10:16:19 +03:00
Aaro KoivukangasandAaro Koivukangas 5ed3408ee7 [Cypress] Acually remove cypress entirely 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas f5da3920d2 [E2E][NumbersMatch] Increase test timeout 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas e75a521284 [RIP][Cypress] Wave goodbye to cypress o7, you will not be missed 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas d25e950d8a [Playwright] Migrate Numbers match 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas cc1ad91b58 [Playwright] Migrate Completed courses 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas ff84e574d6 [Playwright] Migrate students 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 0832dbfe67 [Playwright] Migrate filter tests 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 62bb09e0b5 [E2E][Meta] Change to use better mock date 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 7c5148646c [Playwright] Migrate Custom population 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 6724ec6406 [Playwright] Migrate Study guidance groups 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 82cfb8ece4 [Playwright] Migrate language center view 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas c23db2cd89 [Playwright] Migrate Faculty statistisc 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 2d2344e0c5 [E2E] Fix tests to match mocked date 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas c3b8a8e224 [Playwright] Mock date in helper functions 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 4e9358e69e [GithubActions] Move university tests from cypress to playwright 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas ecaafd3c89 [Lint] Add new lint rule to check backend uses only now() and not new Date() 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas a30b78f74d [Playwright] Migrate university tests 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 1666cad7cc [E2E] Add mocked backend time to init e2e command 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 35f500af0f [TestNowHeader] Add small tests for the x-test-now header 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas ce04b19145 [Meta] Add test header to mock current date 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 701209ed4c [E2E][CourseStatistics] Fix check order 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas f6cc05aa5d [GithubActions] Rename cypress test 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas f42bc4e084 [Lint] Fix 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas e37ec1f9d7 [E2E] Fix broken access tests 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 56fda867c2 [E2E] Fix tests with newPage 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 58f2d3068e [E2E][Users] Acually login as basic-user 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 4dca1ab199 [Lint] Disable lint rule inside e2e 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas a3da0313b4 [E2E] Fix e2e tests broken by curriculum change 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 29f38ff0cc [DegreeProgrammeOverview][StudyTracks] Fix incorrect formatting on NA percentage values 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 836628a22f [DegreeProgrammeOverview] Fix missing empty years without stats 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 0d0169bd87 [ComponentTests] Fix tests that broke because of curriculum changing 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 2132a6bd70 [GithubActions] Disable db image updating for a while 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 2eeb9b0bd0 [DockerCompose] Add host to db healthchecks to wait for initdb scripts to run 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 25c14aac0f [Playwright] Migrate Course_statistics 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas c7cdb60d1d [E2E+GithubActions] Migrated Routes + added it to github 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 06659916d3 [GithubActions] Install only firefox for playwright 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 0871ca9b91 [GithubActions] Enable docker image caching if running inside ubuntu-latest 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 1b87eb02e8 [GithubActions] Add variable to change the running environment in workflows 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 6f53e9cb78 [GithubActions] Move playwright-e2e to the correct directory 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 5054468710 [Updater] Change "weekly" to first schedule programmes, as that takes ages 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 65b69e0a92 [GithubActions] Change skip_db_update to be a string, not a boolean 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 8215747238 [Lint] Don't include node types, just ignore playwright config 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 4d9ae22248 [GithubActions] Change to use relative paths 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas a5c617a9a7 [GithubActions] Run all tests on pull requests, but don't update dbs 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 2c5347fe15 [Playwright] Remove cypress files 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas a6ebad58a7 [Lint] Add @types/node to fix lint error 2026-08-09 01:48:41 +03:00
885611f00f [Lint] Ignore default export in playwright.config (oxlint)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-09 01:48:41 +03:00
4587d66ac2 [Playwright] Fix lint errors in e2e tests and plugins
- Converted loops to Promise.all where appropriate
- Removed inferrable types and unused async params
- Converted Cypress plugins to named export

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas d73f3b5101 [Playwright] Remove unused file 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 944bde4295 [GithubActions] Add playwright tests to actions 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas a47aed615e [Playwright][E2E] Fix flaky test 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 44ca967cdb [Scripts] Move playwright scripts to the correct place 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 90aaf3686f [Playwright][Config] Change better values for ci 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 91ae0f37a3 [Playwright] Remove cypress files from e2e 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 24a6522757 [Playwright] Migrate Population statistics 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 08fc18aab5 [Playwright] Finish Degree programme overview migration 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas fca3c6d1c5 [Playwright] Migrate more of Degree programme overview 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas b0db467c64 [Playwright] Migrate part of Degree programme overview 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas ccc811d55b [Playwright][Commands] Add checkTableStats 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas a070df939b [Playwright] Set better timeout defaults 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 77e6c7f554 [Playwright] Migrate Teachers to playwright 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 312f817694 [Lint] Remove comment 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas b95fa9b7ed [Playwright][Users] Migrate Users tests 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas 17cdcc0800 [Playwright] Change js to ts 2026-08-09 01:48:41 +03:00
Aaro KoivukangasandAaro Koivukangas d0b85c0722 [Playwright] Change playwright to under e2e 2026-08-09 01:48:41 +03:00
42d8c529a3 Migrate Cypress e2e suite to Playwright baseline
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-09 01:48:41 +03:00
400 changed files with 18571 additions and 12472 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"permissions": {
"deny": [
"Read(**/.env*)",
"Read(./.databasedumps/**)",
"Read(**/*.sqz)",
"Read(**/*.sql)",
"Bash(docker exec *-db *)",
"Bash(*psql *)"
]
}
}
+5 -1
View File
@@ -3,4 +3,8 @@
# `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
IMPORTER_DB_API_TOKEN=
PATE_TOKEN=
CRYPT_KEY=
CRYPT_KEY=
# Changes the user and group docker runs as. Use "id -u" and "id -g" to run containers as current user+group.
# Fixes errors with permissions when mounting.
DOCKER_UID=
DOCKER_GID=
+1 -1
View File
@@ -8,6 +8,6 @@
"**/coverage/**",
"nyc-config.cjs",
".lintstagedrc.mjs",
"cypress.config.js"
"playwright/**/*.js"
]
}
-15
View File
@@ -24,21 +24,6 @@ runs:
username: ${{ inputs.username }}
password: ${{ inputs.password }}
- name: Restore cached docker images
id: cache-images-restore
uses: actions/cache/restore@v6
with:
path: |
/tmp/docker-images.tar.zst
key: docker-images-${{ github.sha }}
- name: Load Docker images (or skip if missing)
shell: bash
run: |
if [ -f /tmp/docker-images.tar.zst ]; then
zstd -d /tmp/docker-images.tar.zst --stdout | docker load
fi
- name: Start oodikone (without frontend and backend)
shell: bash
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
@@ -1,5 +1,5 @@
name: 'Run tests'
description: 'Runs Cypress tests for individual spec'
name: Run e2e tests with playwright
description: Runs playwright tests for individual spec
inputs:
username:
@@ -10,7 +10,7 @@ inputs:
description: 'Toska registry password'
spec:
required: true
description: 'Path to Cypress file'
description: 'Path to test file'
test_name:
required: true
description: 'Name of the test'
@@ -29,19 +29,10 @@ runs:
shell: bash
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
- name: Run Cypress inside docker container
- name: Run playwright tests (${{ inputs.test_name }}})
shell: bash
# Permission errors? See: https://github.com/cypress-io/cypress-docker-images/blob/master/examples/included-as-non-root/README.md#run-as-non-root
run: |
docker run --user node --rm -v $PWD:/e2e -w /e2e --add-host host.docker.internal:host-gateway -e CYPRESS_VIDEO -e CYPRESS_baseUrl=http://host.docker.internal:1337 --entrypoint cypress cypress/included:15.18.1 run --browser firefox --spec ${{ inputs.spec }}
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v7
if: failure()
with:
name: 'Cypress videos - ${{ inputs.test_name }}'
path: /tmp/cypress/videos/*
retention-days: 7
CI='true' npm run playwright ${{ inputs.spec }}
- name: 'Print logs on failure'
shell: bash
-15
View File
@@ -24,21 +24,6 @@ runs:
username: ${{ inputs.username }}
password: ${{ inputs.password }}
- name: Restore cached docker images
id: cache-images-restore
uses: actions/cache/restore@v6
with:
path: |
/tmp/docker-images.tar.zst
key: docker-images-${{ github.sha }}
- name: Load Docker images (or skip if missing)
shell: bash
run: |
if [ -f /tmp/docker-images.tar.zst ]; then
zstd -d /tmp/docker-images.tar.zst --stdout | docker load
fi
- name: Start oodikone (without frontend and backend)
shell: bash
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
@@ -0,0 +1,18 @@
name: 'Run updater unit tests'
description: 'Runs unit tests in sis-updater-worker'
runs:
using: composite
steps:
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
shell: bash
run: npm ci --prefix ./updater/sis-updater-worker
- name: Run updater unit tests
shell: bash
run: npm run test:unit --prefix ./updater/sis-updater-worker
+14 -8
View File
@@ -7,7 +7,8 @@ on:
jobs:
release_backend:
name: 'Build and release backend'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
@@ -27,7 +28,8 @@ jobs:
release_frontend:
name: 'Build and release frontend'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
@@ -48,7 +50,8 @@ jobs:
sentry_release_frontend:
name: 'Create Sentry release for frontend'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs:
- release_frontend
- release_backend
@@ -82,7 +85,8 @@ jobs:
sentry_release_backend:
name: 'Create Sentry release for backend'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs:
- release_frontend
- release_backend
@@ -116,7 +120,8 @@ jobs:
report_success:
name: 'Report deployment success to the "oodikone_notifications" channel'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs:
- release_frontend
- release_backend
@@ -128,7 +133,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
uses: UniversityOfHelsinkiCS/slackbot-action@v1.6.1
with:
webhook-url: ${{ secrets.WEBHOOK_URL }}
message-type: deployment
@@ -137,7 +142,8 @@ jobs:
report_failure:
name: 'Report deployment failure to the "oodikone_notifications" channel'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs:
- release_frontend
- release_backend
@@ -149,7 +155,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
uses: UniversityOfHelsinkiCS/slackbot-action@v1.6.1
with:
webhook-url: ${{ secrets.WEBHOOK_URL }}
message-type: deployment-failure
+12 -83
View File
@@ -10,92 +10,21 @@ on:
- '.github/workflows/test.yaml'
- 'services/**'
- 'updater/**'
- 'e2e/**'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- '.oxlintrc.json'
- '.oxlint-backend-rules.cjs'
- 'package*.json'
- 'cypress/**'
- 'cypress.config.json'
jobs:
lint_packages:
name: 'Lint packages'
runs-on: ubuntu-latest
test:
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
steps:
- uses: actions/checkout@v7
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
test_unit:
name: 'Run unit tests'
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
needs:
- lint_packages
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/test/unit
test_api:
name: 'Test backend api'
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
needs:
- lint_packages
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/test/api
test_end_to_end:
name: ${{ matrix.name }}
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
needs:
- lint_packages
- test_api
strategy:
fail-fast: false
matrix:
include:
- name: 'Test course statistics'
spec: cypress/e2e/Course_statistics.js
- name: 'Test custom population'
spec: cypress/e2e/Custom_population.js
- name: 'Test population statistics'
spec: cypress/e2e/Population_statistics.js
- name: 'Test filters'
spec: cypress/e2e/Filters.js
- name: 'Test students'
spec: cypress/e2e/Students.js
- name: 'Test completed courses'
spec: cypress/e2e/Completed_courses.js
- name: 'Test degree programme overview'
spec: cypress/e2e/Degree_programme_overview.js
- name: 'Test teachers and users'
spec: |
cypress/e2e/Teachers.js
cypress/e2e/Users.js
- name: 'Test faculty statistics'
spec: cypress/e2e/Faculty_statistics.js
- name: 'Test language center view'
spec: cypress/e2e/Language_center.js
- name: 'Test study guidance groups'
spec: cypress/e2e/Study_guidance_groups.js
- name: 'Test university'
spec: cypress/e2e/University.js
- name: 'Test numbers match'
spec: cypress/e2e/Numbers_match.js
steps:
- uses: actions/checkout@v7
- name: 'Run tests'
uses: ./.github/actions/test/e2e
with:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
spec: ${{ matrix.spec }}
test_name: ${{ matrix.name }}
uses: ./.github/workflows/test.yaml
with:
skip_db_update: true
secrets:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
s3_access_key: ${{ secrets.S3_ACCESS_KEY }}
s3_secret_key: ${{ secrets.S3_SECRET_KEY }}
+19 -10
View File
@@ -10,11 +10,12 @@ on:
- '.github/workflows/test.yaml'
- 'services/**'
- 'updater/**'
- 'e2e/**'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- '.oxlintrc.json'
- '.oxlint-backend-rules.cjs'
- 'package*.json'
- 'cypress/**'
- 'cypress.config.json'
jobs:
test:
@@ -22,10 +23,13 @@ jobs:
secrets:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
s3_access_key: ${{ secrets.S3_ACCESS_KEY }}
s3_secret_key: ${{ secrets.S3_SECRET_KEY }}
release_backend:
name: 'Build and release backend'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs: test
steps:
@@ -42,7 +46,8 @@ jobs:
release_frontend:
name: 'Build and release frontend'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs: test
steps:
@@ -59,7 +64,8 @@ jobs:
release_updater_worker:
name: 'Build and release updater worker'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs: test
steps:
@@ -76,7 +82,8 @@ jobs:
release_updater_scheduler:
name: 'Build and release updater scheduler'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs: test
steps:
@@ -93,7 +100,8 @@ jobs:
report_success:
name: 'Report deployment success to the "oodikone_notifications" channel'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: success()
needs:
- release_frontend
@@ -104,7 +112,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
uses: UniversityOfHelsinkiCS/slackbot-action@v1.6.1
with:
webhook-url: ${{ secrets.WEBHOOK_URL }}
message-type: deployment
@@ -113,7 +121,8 @@ jobs:
report_failure:
name: 'Report deployment failure to the "oodikone_notifications" channel'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: failure()
needs:
- release_frontend
@@ -124,7 +133,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: 'Send notification to Slack'
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
uses: UniversityOfHelsinkiCS/slackbot-action@v1.6.1
with:
webhook-url: ${{ secrets.WEBHOOK_URL }}
message-type: deployment-failure
+126 -143
View File
@@ -2,6 +2,12 @@ name: 'Lint and test'
on:
workflow_call:
inputs:
skip_db_update:
description: 'When true, skip committing/pushing anon DB images (used for PR runs)'
required: false
type: string
default: 'false'
secrets:
username:
required: true
@@ -9,12 +15,19 @@ on:
password:
required: true
description: 'Toska registry password'
s3_access_key:
required: true
description: 'S3 access key for the test database dumps'
s3_secret_key:
required: true
description: 'S3 secret key for the test database dumps'
jobs:
# === Static tests / linting ===
lint_packages:
name: 'Lint packages'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Lint backend, frontend and shared
@@ -23,7 +36,8 @@ jobs:
# === Build database images ===
build_backend:
name: 'Build backend'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Build backend
@@ -35,7 +49,8 @@ jobs:
build_frontend:
name: 'Build frontend'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Build frontend
@@ -45,61 +60,11 @@ jobs:
release_version: ${{ github.sha }}
push: false
# === Cache docker images ===
# TODO: Disabled when running in self-hosted runner
pull_docker_images:
name: Pull all needed docker images and cache them (if dbs have not changed)
runs-on: self-hosted
if: |
!cancelled() && !failure() && false &&
!(
needs.check_updater_changed.outputs.has_updater_changed == 'true' ||
needs.check_migration_files.outputs.kone_db_migrations == 'true' ||
needs.check_migration_files.outputs.user_db_migrations == 'true' ||
needs.check_migration_files.outputs.sis_db_migrations == 'true'
)
needs:
- check_updater_changed
- check_migration_files
steps:
- uses: actions/checkout@v7
- name: Login to toska docker registry
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: Set up cache for pulled docker images (saved in Post-step)
id: cache-images
uses: actions/cache@v6
with:
path: /tmp/docker-images.tar.zst
key: docker-images-${{ github.sha }}
- name: Pull required images
if: steps.cache-images.outputs.cache-hit != 'true'
run: docker compose --file docker-compose.ci.yml --profile oodikone --profile updater pull --ignore-buildable --quiet
- name: Save Docker images for downstream jobs
if: steps.cache-images.outputs.cache-hit != 'true'
run: |
docker save \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db \
"quay.io/toska/jami:production" \
redis:7.2.6 \
postgres:15.10 \
nginx:latest \
| zstd -T0 -3 -o /tmp/docker-images.tar.zst
# === Update databases ===
check_migration_files:
name: Check if new migrations have been added
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
outputs:
kone_db_migrations: ${{ steps.check_migrations.outputs.kone_db_migrations }}
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
@@ -124,16 +89,19 @@ jobs:
build_and_push_anon_sis_db:
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.sis_db_migrations == 'true'
needs.check_migration_files.outputs.sis_db_migrations == 'true' &&
inputs.skip_db_update != 'true'
needs:
- check_migration_files
- build_backend
- build_frontend
- lint_packages
- test_unit
- test_updater_unit
steps:
- uses: actions/checkout@v7
@@ -145,10 +113,14 @@ jobs:
password: ${{ secrets.password }}
- name: 'Let sis-updater-worker run migrations, then push new version of image to toska hub'
env:
ACCESS_KEY: ${{ secrets.s3_access_key }}
SECRET_KEY: ${{ secrets.s3_secret_key }}
run: |
docker compose --file docker-compose.ci.yml up --detach --quiet-pull sis-updater-worker
sleep 30 # wait for sis-updater-worker to start and run migrations
./anonyymioodi/commit_and_push_to_toska_hub.sh sis-db
sudo apt update -y && sudo apt install s3cmd -y
./anonyymioodi/manage_test_dbs.sh release sis-db
- name: Teardown containers started by compose
if: always()
@@ -156,16 +128,19 @@ jobs:
build_and_push_anon_kone_db:
name: 'Build anon kone-db and push to toska hub if new migrations have been added'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.kone_db_migrations == 'true'
needs.check_migration_files.outputs.kone_db_migrations == 'true' &&
inputs.skip_db_update != 'true'
needs:
- check_migration_files
- build_backend
- build_frontend
- lint_packages
- test_unit
- test_updater_unit
steps:
- uses: actions/checkout@v7
@@ -178,10 +153,14 @@ jobs:
password: ${{ secrets.password }}
- name: 'Let backend run migrations, then push new version of image to toska hub'
env:
ACCESS_KEY: ${{ secrets.s3_access_key }}
SECRET_KEY: ${{ secrets.s3_secret_key }}
run: |
docker compose --file docker-compose.ci.yml up --detach --quiet-pull backend --wait
sleep 30 # wait for backend to start and run migrations
./anonyymioodi/commit_and_push_to_toska_hub.sh kone-db
sudo apt update -y && sudo apt install s3cmd -y
./anonyymioodi/manage_test_dbs.sh release kone-db
- name: Teardown containers started by compose
if: always()
@@ -189,16 +168,19 @@ jobs:
build_and_push_anon_user_db:
name: 'Build anon user-db and push to toska hub if new migrations have been added'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.user_db_migrations == 'true'
needs.check_migration_files.outputs.user_db_migrations == 'true' &&
inputs.skip_db_update != 'true'
needs:
- check_migration_files
- build_backend
- build_frontend
- lint_packages
- test_unit
- test_updater_unit
steps:
- uses: actions/checkout@v7
@@ -211,10 +193,14 @@ jobs:
password: ${{ secrets.password }}
- name: 'Let backend run migrations, then push new version of image to toska hub'
env:
ACCESS_KEY: ${{ secrets.s3_access_key }}
SECRET_KEY: ${{ secrets.s3_secret_key }}
run: |
docker compose --file docker-compose.ci.yml up --detach --quiet-pull backend --wait
sleep 30 # wait for backend to start and run migrations
./anonyymioodi/commit_and_push_to_toska_hub.sh user-db
sudo apt update -y && sudo apt install s3cmd -y
./anonyymioodi/manage_test_dbs.sh release user-db
- name: Teardown containers started by compose
if: always()
@@ -223,7 +209,8 @@ jobs:
# === Update sis-db with updater ===
check_updater_changed:
name: 'Check if updater has been updated'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
outputs:
@@ -243,7 +230,8 @@ jobs:
run_updater_updates:
name: 'Run updater tests and updates'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure() &&
(needs.check_updater_changed.outputs.has_updater_changed == 'true' ||
@@ -279,9 +267,15 @@ jobs:
EXIT_AFTER_IMMEDIATES: yes
# TODO: Push the updated sis-db to toska-hub when updater is deterministic with test-data
# - name: Push newly build db to toska hub
# if: success()
# run: ./anonyymioodi/commit_and_push_to_toska_hub.sh sis-db
- name: Push newly build db to toska hub
if: |
success() && inputs.skip_db_update != 'true'
env:
ACCESS_KEY: ${{ secrets.s3_access_key }}
SECRET_KEY: ${{ secrets.s3_secret_key }}
run: |
sudo apt update -y && sudo apt install s3cmd -y
./anonyymioodi/manage_test_dbs.sh release sis-db
- name: Dump logs on failure
if: failure()
@@ -294,16 +288,14 @@ jobs:
# === Backend api-tests ===
test_api:
name: 'Run backend api tests'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
needs:
- pull_docker_images
- run_updater_updates
- build_and_push_anon_user_db
- build_and_push_anon_kone_db
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
@@ -316,16 +308,14 @@ jobs:
# === Backend service-tests ===
test_services:
name: 'Run backend service-tests'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
needs:
- pull_docker_images
- run_updater_updates
- build_and_push_anon_user_db
- build_and_push_anon_kone_db
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v7
@@ -338,7 +328,8 @@ jobs:
# === Unit testing ===
test_unit:
name: 'Run unit tests'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
@@ -347,14 +338,27 @@ jobs:
- name: Run unit tests (frontend and backend)
uses: ./.github/actions/test/unit
# === Updater unit testing ===
test_updater_unit:
name: 'Run updater unit tests'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
steps:
- uses: actions/checkout@v7
- name: Run updater unit tests
uses: ./.github/actions/test/updater-unit
# === Frontend component testing ===
test_components:
name: 'Test frontend components'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
needs:
- pull_docker_images
- run_updater_updates
- build_and_push_anon_user_db
- build_and_push_anon_kone_db
@@ -365,12 +369,12 @@ jobs:
uses: ./.github/actions/test/component
# === End-to-end tests ===
test_end_to_end:
runs-on: self-hosted
test_end_to_end_playwright:
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
needs:
- pull_docker_images
- run_updater_updates
- build_and_push_anon_user_db
- build_and_push_anon_kone_db
@@ -378,62 +382,55 @@ jobs:
fail-fast: false
matrix:
include:
- name: 'Test course statistics'
spec: cypress/e2e/Course_statistics.js
- name: 'Test custom population'
spec: cypress/e2e/Custom_population.js
- name: 'Test population statistics'
spec: cypress/e2e/Population_statistics.js
- name: 'Test filters'
spec: cypress/e2e/Filters.js
- name: 'Test students'
spec: cypress/e2e/Students.js
- name: 'Test completed courses'
spec: cypress/e2e/Completed_courses.js
- name: 'Test degree programme overview'
spec: cypress/e2e/Degree_programme_overview.js
- name: 'Test teachers'
spec: cypress/e2e/Teachers.js
- name: 'Test users'
spec: cypress/e2e/Users.js
- name: 'Test faculty statistics'
spec: cypress/e2e/Faculty_statistics.js
- name: 'Test language center view'
spec: cypress/e2e/Language_center.js
- name: 'Test study guidance groups'
spec: cypress/e2e/Study_guidance_groups.js
- name: 'Test university'
spec: cypress/e2e/University.js
- name: 'Test numbers match'
spec: cypress/e2e/Numbers_match.js
- name: 'Test access rights'
spec: cypress/e2e/Routes.js
- name: 'Population statistics'
spec: e2e/Population_statistics.spec.ts
- name: 'Course statistics'
spec: e2e/Course_statistics.spec.ts
- name: 'Degree programme overview'
spec: e2e/Degree_programme_overview.spec.ts
- name: 'faculty statistics'
spec: e2e/Faculty_statistics.spec.ts
- name: 'Teachers'
spec: e2e/Teachers.spec.ts
- name: 'Users'
spec: e2e/Users.spec.ts
- name: 'Access rights'
spec: e2e/Routes.spec.ts
- name: 'University'
spec: e2e/University.spec.ts
- name: 'Language center view'
spec: e2e/Language_center.spec.ts
- name: 'Study guidance groups'
spec: e2e/Study_guidance_groups.spec.ts
- name: 'Custom population'
spec: e2e/Custom_population.spec.ts
- name: 'Filters'
spec: e2e/Filters.spec.ts
- name: 'Students'
spec: e2e/Students.spec.ts
- name: 'Completed courses'
spec: e2e/Completed_courses.spec.ts
- name: 'Numbers match'
spec: e2e/Numbers_match.spec.ts
name: ${{ matrix.name }}
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install node-24 for cypress
- name: Install node-24 for playwright
uses: actions/setup-node@v7
with:
node-version: 24
- name: Restore cached docker images
id: cache-images-restore
uses: actions/cache/restore@v6
with:
path: |
/tmp/docker-images.tar.zst
key: docker-images-${{ github.sha }}
- name: Load Docker images (or skip if missing)
- name: Install dependencies
run: |
if [ -f /tmp/docker-images.tar.zst ]; then
zstd -d /tmp/docker-images.tar.zst --stdout | docker load
fi
npm ci
- name: Install Playwright Browsers
run: npx playwright install firefox --with-deps
- name: Run tests
uses: ./.github/actions/test/e2e
uses: ./.github/actions/test/e2e-playwright
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
@@ -443,11 +440,11 @@ jobs:
# === Other tests ===
test_service_exit_statuses:
name: 'Check services run correctly'
runs-on: self-hosted
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
needs:
- pull_docker_images
- run_updater_updates
- build_and_push_anon_user_db
- build_and_push_anon_kone_db
@@ -461,20 +458,6 @@ jobs:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: Restore cached docker images
id: cache-images-restore
uses: actions/cache/restore@v6
with:
path: |
/tmp/docker-images.tar.zst
key: docker-images-${{ github.sha }}
- name: Load Docker images (or skip if missing)
run: |
if [ -f /tmp/docker-images.tar.zst ]; then
zstd -d /tmp/docker-images.tar.zst --stdout | docker load
fi
- name: 'Run services, build rest of the needed images'
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull --wait
+22 -6
View File
@@ -10,7 +10,8 @@ on:
jobs:
lint_scheduler_and_worker:
name: 'Lint scheduler and worker'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
@@ -22,9 +23,19 @@ jobs:
- name: 'Run linter'
run: npm run lint -- updater
test_updater_unit:
name: 'Run updater unit tests'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Run updater unit tests
uses: ./.github/actions/test/updater-unit
build_worker:
name: 'Build Updater worker'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
@@ -48,7 +59,8 @@ jobs:
build_scheduler:
name: 'Build Updater scheduler'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
@@ -72,9 +84,11 @@ jobs:
test_updater:
name: 'Test Updater'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs:
- lint_scheduler_and_worker
- test_updater_unit
- build_worker
- build_scheduler
@@ -127,7 +141,8 @@ jobs:
release_worker:
name: 'Build and release Updater worker'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs: test_updater
steps:
- uses: actions/checkout@v7
@@ -143,7 +158,8 @@ jobs:
release_scheduler:
name: 'Build and release Updater scheduler'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs: test_updater
steps:
- uses: actions/checkout@v7
+2
View File
@@ -7,6 +7,8 @@ yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
test-results
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+1 -1
View File
@@ -1,6 +1,6 @@
npx lint-staged
if git grep -qE --cached "(it|describe)\.only" $(git diff --cached --name-only --diff-filter=ACMR | xargs) ./cypress; then
if git grep -qE --cached "(it|describe)\.only" $(git diff --cached --name-only --diff-filter=ACMR | xargs); then
echo "\e[0;31mDo not commit .only-tests."
exit 1
else
+13 -14
View File
@@ -9,25 +9,24 @@ export default {
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
`oxlint --fix --type-aware --type-check --no-error-on-unmatched-pattern --quiet ${files.join(' ')}`,
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files =>
`oxfmt --no-error-on-unmatched-pattern ${files.join(' ')} `,
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `oxfmt --no-error-on-unmatched-pattern ${files.join(' ')} `,
'*.css': files => `stylelint --fix ${files.join(' ')}`,
Dockerfile: files =>
`${dockerCmdBase} hadolint/hadolint:v2.14.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.11.0 ${relativeFilePaths(files)} -x`,
'docker-compose*': files => {
const composeFiles = file => {
if (['docker-compose.ci.yml', 'docker-compose.test.yml'].some(f => file.includes(f))) {
return `--file ${file}`
}
if (file.includes('docker-compose.real.yml')) {
return `--file ${cwd}/docker-compose.yml --file ${file}`
}
return ''
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.11.0 ${relativeFilePaths(files)} -x`,
'docker-compose*': files => {
const composeFiles = file => {
if (['docker-compose.ci.yml', 'docker-compose.test.yml'].some(f => file.includes(f))) {
return `--file ${file}`
}
if (file.includes('docker-compose.real.yml')) {
return `--file ${cwd}/docker-compose.yml --file ${file}`
}
return ''
}
return files.map(file => `docker compose ${composeFiles(file)} config --quiet`)
},
return files.map(file => `docker compose ${composeFiles(file)} config --quiet`)
},
}
+56
View File
@@ -0,0 +1,56 @@
module.exports = {
meta: {
name: 'oodikone',
},
rules: {
'no-date-now': {
meta: {
type: 'suggestion',
docs: {
description: 'Require the backend clock helper instead of Date.now()',
},
schema: [],
messages: {
useNow: 'Use now().getTime() instead of Date.now() so the current time can be controlled in tests.',
},
},
create(context) {
return {
CallExpression(node) {
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'Date' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'now' &&
node.arguments.length === 0
) {
context.report({ node, messageId: 'useNow' })
}
},
}
},
},
'no-new-date': {
meta: {
type: 'suggestion',
docs: {
description: 'Require the backend clock helper for the current date',
},
schema: [],
messages: {
useNow: 'Use now() instead of new Date() so the current time can be controlled in tests.',
},
},
create(context) {
return {
NewExpression(node) {
if (node.callee.type === 'Identifier' && node.callee.name === 'Date' && node.arguments.length === 0) {
context.report({ node, messageId: 'useNow' })
}
},
}
},
},
},
}
+18 -22
View File
@@ -1,6 +1,7 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["import", "typescript"],
"jsPlugins": ["./.oxlint-backend-rules.cjs"],
"categories": {
"correctness": "off"
},
@@ -36,6 +37,7 @@
},
"ignorePatterns": [
"eslint.config.mjs",
".oxlint-backend-rules.cjs",
"**/build/**",
"**/dist/**",
"**/node_modules/**",
@@ -43,8 +45,8 @@
"**/coverage/**",
"nyc-config.cjs",
".lintstagedrc.mjs",
"cypress.config.js",
"cypress/**/*.js"
"playwright/**/*.js",
"playwright.config.ts"
],
"rules": {
"constructor-super": "error",
@@ -233,6 +235,14 @@
"prefer-spread": "error"
}
},
{
"files": ["services/backend/src/**/*.{js,ts,tsx}"],
"excludeFiles": ["services/backend/src/util/clock.ts"],
"rules": {
"oodikone/no-new-date": "error",
"oodikone/no-date-now": "error"
}
},
{
"files": ["services/backend/src/routes/*.{ts,js}"],
"rules": {
@@ -337,26 +347,6 @@
},
"plugins": ["react"]
},
{
"files": ["cypress/**/*.js"],
"rules": {
"cypress/no-assigning-return-values": "error",
"cypress/no-unnecessary-waiting": "error",
"cypress/no-async-tests": "error",
"cypress/unsafe-to-chain-command": "error"
},
"jsPlugins": ["eslint-plugin-cypress"],
"globals": {
"cy": "readonly",
"Cypress": "readonly",
"expect": "readonly",
"assert": "readonly",
"chai": "readonly"
},
"env": {
"mocha": true
}
},
{
"files": ["services/backend/tests/**/*.ts"],
"rules": {
@@ -389,6 +379,12 @@
"import/no-default-export": "off"
}
},
{
"files": ["e2e/**/*.ts"],
"rules": {
"no-await-in-loop": "off"
}
},
{
"files": [
"services/frontend/tests/**/*.{ts,tsx}",
-11
View File
@@ -1,11 +0,0 @@
{
"semi": false,
"arrowParens": "avoid",
"singleQuote": true,
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"jsxSingleQuote": false,
"trailingComma": "es5",
"endOfLine": "lf"
}
-9
View File
@@ -1,9 +0,0 @@
{
"recommendations": [
"aaron-bond.better-comments",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"github.vscode-github-actions",
"stylelint.vscode-stylelint"
]
}
+63
View File
@@ -0,0 +1,63 @@
# AGENTS.md
Oodikone is a Docker-based Node.js/TypeScript application for analysing University of Helsinki study data. Main areas are:
- `services/frontend`: React, Vite, Redux Toolkit, and Material UI.
- `services/backend`: Express, Sequelize, PostgreSQL, Redis, and BullMQ.
- `services/shared`: shared types and utilities.
- `updater/`: Sisu data workers and scheduler.
- `e2e/`: Playwright e2e tests
Use Node.js 24 or newer. Packages have separate dependencies and lockfiles. Install locally with
`npm run install:local`, or use `npm run ci:local` for lockfile-exact installs. Docker uses host-mounted locks:
update dependencies locally, then run `npm run ci:docker`; do not install in containers.
## Development Rules
- Read nearby code and tests before changing behavior; follow local conventions.
- Keep changes focused and avoid unrelated formatting.
- Reuse `services/shared` for cross-service types and utilities.
- Never commit `.env` files, credentials, database dumps, or real student data.
- Use anonymized data. Do not reset real databases, use destructive commands, or use real-data Docker profiles unless
explicitly required.
- Treat migrations as production-impacting. They are in `services/backend/src/database/migrations_kone`,
`services/backend/src/database/migrations_user`, and `updater/sis-updater-worker/src/db/migrations`; test migration
changes against the relevant database service.
- Do not access the database directly (e.g. `docker exec` into a database container, `psql`, Adminer, or similar).
Go through the application's models or services instead or ask if there is questions about the data.
- Add tests, especially unit tests, when adding or changing behavior.
## Local Commands
```text
npm run oodikone # start the anonymized environment
npm run oodikone:ci # start the test mode
npm run both # start Oodikone and the updater
npm run docker:down # stop containers
docker compose ps # show service status
```
The app runs at `http://localhost:3000` and Adminer at `http://localhost:5050`. Follow logs with
`docker compose logs --follow frontend` or `docker compose logs --follow backend`.
## Style And Verification
- TypeScript/JavaScript: 2 spaces, single quotes, no semicolons, trailing commas where valid, 120-character lines.
- Prefer explicit types at package boundaries, API payloads, Redux state, and database interfaces. Avoid `any` except
where needed to contain an untyped external API.
- Run `npm run fmt` on changed source, `npm run lint`, and `npm run stylelint` when CSS changes. Do not bypass the
type-aware Oxlint/Oxfmt pre-commit hooks.
- Run the narrowest relevant check, then broader checks as needed:
- Frontend: `npm run build --prefix services/frontend`, `npm run test:unit --prefix services/frontend`, or
`npm run test:component --prefix services/frontend`.
- Backend: `npm run build --prefix services/backend`, or `npm run test:unit --prefix services/backend`,
`npm run test:services --prefix services/backend`, or `npm run test:api --prefix services/backend`.
- Shared: `npm run test --prefix services/shared`.
- E2E: `npm run playwright -- e2e/<spec>.spec.ts`.
- If running `docker-compose.ci.yml` add env variable `CI=true` before the e2e npm command.
Otherwise some tests fail.
- API, service, component, and E2E tests require Docker; start the relevant environment with `npm run oodikone:test`
or its documented profile. Report any checks that could not run and why.
CI also validates Docker Compose files, Dockerfiles, shell scripts, workflows, and migrations. Keep such changes
minimal and validate them locally where practical.
+14 -16
View File
@@ -175,19 +175,19 @@ Pre-commit hooks will fix auto-fixable problems. To set up quick formatting: In
## 🔨 Testing & CI
We use cypress for end-to-end testing. No unit tests are currently used.
We use Playwright for end-to-end testing.
Cypress
Playwright
- Can be launched in interactive mode with `npm run cypress open`. `package.json` defines entrypoint `npm run cypress` so you can basically run cypress with any arguments you want
- Are defined in cypress -folder and cypress.config.json
- The entire cypress test stack takes about 15 to 20 mins to run. Since tests are ran in our Github actions CI pipe, you're encouraged to take advantage of this instead of running all tests locally.
- Running tests locally sometimes causes problems after running the whole tests suite due to some login-related issue. Also for this reason, it is usually best practice to only run single views locally, and let the CI run the entire test suite.
- There are some different user types and cypress commands defined for testing. Take a look at these when debugging tests.
- E2E specs are in the `e2e/` folder.
- Run all e2e specs with `npm run playwright`.
- Run in headed mode with `npm run playwright:headed`.
- Open in UI mode with `npm run playwright:ui`.
- If using a command that runs `docker-compose.ci.yml`, remember to add CI=true before the playwright command, eg. `CI=true npm run playwright`.
Continuous integration (CI) works with Github actions and is defined in workflow files in `.github/workflows` folder:
- Oodikone setup for cypress and other tests in CI is defined in `docker-compose.ci.yml`. Take a look at this too if debugging github action workflows.
- Oodikone setup for e2e and other tests in CI is defined in `docker-compose.ci.yml`. Take a look at this too if debugging github action workflows.
- Tests are run on every push
- After a successful test run, Oodikone is deployed to staging
- After creating a release, Oodikone is deployed to production
@@ -216,39 +216,37 @@ If that does not help, try option 5: _Docker system prune_. Notice that this cle
### How to run tests locally faster
Vite simplifies development but can be **very slow** when running tests. If you're curious about the reasons for this, you can read more in [this GitHub issue](https://github.com/cypress-io/cypress/issues/22968).
Vite simplifies development but can be **very slow** when running tests.
To speed up testing, you can use Vite's preview mode. This will make the tests run faster, but keep in mind that you must rebuild the frontend code every time you make changes to it. To use the preview mode, follow these steps:
1. Run `npm run build` in the `services/frontend` directory.
2. Run `npm run preview` in the `services/frontend` directory and keep it running.
3. Update the `baseUrl` in [cypress.config.js](./cypress.config.js) to `http://localhost:4173`.
3. Set `PLAYWRIGHT_BASE_URL=http://localhost:4173` for Playwright.
4. Start Oodikone with `npm run oodikone`.
After these steps, you can run your tests:
- Use `npm run cypress open` to open the Cypress UI and run the tests interactively.
- Use `npm run playwright:ui` to open Playwright UI and run tests interactively.
Tests can also be run in headless mode, similar to how they are run in the CI/CD pipeline. To do this, use:
```bash
npm run cypress:run -- --spec <file path>
npm run playwright -- <file path>
```
For example:
```bash
npm run cypress:run -- --spec "cypress/e2e/Language_center.js"
npm run playwright -- e2e/Language_center.js
```
Running tests in headless mode may also help if your browser keeps crashing when running tests interactively. If you want to run all tests, use:
```bash
npm run cypress:run
npm run playwright
```
Remember to revert the `baseUrl` in [cypress.config.js](./cypress.config.js) to its original value when you're done.
## How to read clients minds
Search for client queries in grafana using a variation of this query:
+24
View File
@@ -4,6 +4,30 @@ This folder contains a guide and scripts to modify or create anonymous database
The quick guide should be enough for most purposes: updating schema, data, or the PostgreSQL version. If you need to generate completely new `sis-importer-db` and `sis-db` images (which shouldn't happen too often), follow the instructions in the section [Generate new sis-importer-db and sis-db completely from scratch](#generate-new-sis-importer-db-and-sis-db-completely-from-scratch).
## Scripts
`manage_test_dbs.sh` is the front end for the test database tooling. Run it without arguments for an interactive menu, or give it an action and one or more databases (or `all`) to run it directly:
```bash
./manage_test_dbs.sh dump sis-db # dump sis-db from its running container
./manage_test_dbs.sh upload sis-db # upload the current dump to s3
./manage_test_dbs.sh release all # dump, upload and push images for every database
```
Actions are `download`, `dump`, `upload` (upload + prune), `push`, `prune`, `release` (dump + upload + prune + push) and `rebuild` (download + push). Each one is a thin call to a script in this folder, which can also be used on its own:
| Script | What it does |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `dump_local_db.sh` | `pg_dump`s the databases from the running containers into `.databasedumps/` |
| `download_dump_from_s3.sh` | Downloads the newest dump of each database from s3 (`-b <branch>` to pick dumps from a single branch) |
| `upload_dump_to_s3.sh` | Uploads the dumps in `.databasedumps/test` to s3 as `<database>-<git_branch>-<datetime>.sql` |
| `prune_s3_dumps.sh` | Deletes dumps older than 30 days from s3, always keeping the newest `master` dump of each database (`-d <days>`, `-n`) |
| `build_and_push_image.sh` | Builds database images from the current dumps and pushes them to the Toska registry |
| `create_s3_bucket.sh` | Creates the s3 bucket if it doesn't exist yet, run automatically before an upload |
| `lib/common.sh` | Shared configuration (dump directories, s3 bucket, registry, database names) |
Dumps end up in `.databasedumps/test/<database>.sql`, which is where `db.Dockerfile` reads them from when an image is built. The s3 commands read their credentials from the `ACCESS_KEY` and `SECRET_KEY` environment variables (e.g. in CI). If neither is set, they fall back to `~/.s3cfg`, which can be found on the toska/dokumentaatio repo.
## Quick guide to updating a test database
1. Ensure you have the latest test-db. Just pulling images might not replace your existing volume, so you may need to remove it manually. This command might work: `docker volume rm oodikone-sis-db-data` (replace `oodikone-sis-db-data` with the correct volume name). The new image, including data, should be pulled the next time you launch Oodikone normally.
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Build database images from the dumps in .databasedumps/test and push them to
# the Toska registry.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR"/lib/common.sh
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") <database>... | all
Builds an image of each given database and pushes it to $REGISTRY.
The dump is read from .databasedumps/test/<database>.sql, so run
dump_local_db.sh or download_dump_from_s3.sh first.
Databases: ${DATABASES[*]}
EOF
}
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
usage
exit 0
fi
if [[ $# -eq 0 ]]; then
usage
die "No database given"
fi
build_and_push_image() {
local database="$1"
local image="$REGISTRY/$database:latest"
local dump_file="$TEST_DUMP_DIR/$database.sql"
[[ -f "$dump_file" ]] || die "No dump found at $dump_file, run dump_local_db.sh or download_dump_from_s3.sh first"
infomsg "Building $image"
docker build "$PROJECT_ROOT" -f "$PROJECT_ROOT/db.Dockerfile" -t "$image" --build-arg DB_NAME="$database"
infomsg "Pushing $image"
docker push "$image"
successmsg "Pushed $image"
}
resolve_databases "$@"
for database in "${selected_databases[@]}"; do
build_and_push_image "$database"
done
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Create the s3 bucket used for the test database dumps, if it doesn't exist yet.
# Doing nothing when the bucket is already there, so this is safe to run every time.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR"/lib/common.sh
usage() {
cat >&2 <<EOF
Usage: $(basename "$0")
Creates the bucket $S3_BUCKET if it doesn't exist yet, and does nothing if it does.
EOF
}
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
usage
exit 0
fi
require_s3_config
# s3cmd info exits with an error when the bucket doesn't exist
if s3 info "$S3_BUCKET" > /dev/null 2>&1; then
infomsg "Bucket $S3_BUCKET already exists"
exit 0
fi
warningmsg "Bucket $S3_BUCKET doesn't exist, creating it"
s3 mb "$S3_BUCKET"
successmsg "Created bucket $S3_BUCKET"
+5 -3
View File
@@ -2,9 +2,11 @@ x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
interval: 30s # Normal healthcehck interval
timeout: 5s
retries: 10 # Retries until SIGKILl
start_interval: 3s # How often healthcheck runs while starting
start_period: 15s # How long startup period is
services:
kone-db:
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Download database dumps from s3 into .databasedumps/s3, and copy them to
# .databasedumps/test where db.Dockerfile reads them from.
#
# Dumps are uploaded as <db_name>-<git_branch>-<datetime>.sql (see
# upload_dump_to_s3.sh), so the newest matching dump is the one downloaded.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR"/lib/common.sh
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") [-b|--branch <branch>] <database>... | all
Downloads the newest dump of each given database from $S3_TARGET.
Databases: ${DATABASES[*]}
Options:
-b, --branch <branch> Only consider dumps uploaded from this git branch
-h, --help Show this help
EOF
}
branch=""
arguments=()
while [[ $# -gt 0 ]]; do
case "$1" in
-b | --branch)
branch="${2-}"
[[ -n "$branch" ]] || {
usage
die "Missing value for $1"
}
shift 2
;;
-h | --help)
usage
exit 0
;;
-*)
usage
die "Unknown option: $1"
;;
*)
arguments+=("$1")
shift
;;
esac
done
# Newest object in s3 for the given database, or empty if there are none
newest_dump_in_s3() {
local database="$1"
local prefix="$S3_TARGET/$database-"
if [[ -n "$branch" ]]; then prefix="$prefix$(sanitize_branch_name "$branch")-"; fi
# s3cmd ls prints "<date> <time> <size> <object>", so sorting by the first two
# columns in reverse puts the newest dump first
s3 ls "$prefix" | sort -rk1,2 | awk 'NR == 1 { print $4 }'
}
download_database() {
local database="$1"
local dump_file="$S3_DUMP_DIR/$database.sql"
local newest_dump
newest_dump="$(newest_dump_in_s3 "$database")"
[[ -n "$newest_dump" ]] || die "No dumps found for $database in $S3_TARGET${branch:+ (branch $branch)}"
infomsg "Downloading $newest_dump"
s3 get --force "$newest_dump" "$dump_file"
cp --remove-destination "$dump_file" "$TEST_DUMP_DIR/$database.sql"
successmsg "Downloaded $newest_dump to $dump_file"
}
if [[ ${#arguments[@]} -eq 0 ]]; then
usage
die "No database given"
fi
resolve_databases "${arguments[@]}"
require_s3_config
make_dump_dirs
for database in "${selected_databases[@]}"; do
download_database "$database"
done
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Dump one or more databases from the running docker containers into
# .databasedumps/local_containers, and copy them to .databasedumps/test where
# db.Dockerfile reads them from.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR"/lib/common.sh
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") <database>... | all
Dumps the given databases from their running containers.
Databases: ${DATABASES[*]}
EOF
}
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
usage
exit 0
fi
if [[ $# -eq 0 ]]; then
usage
die "No database given"
fi
dump_database() {
local database="$1"
local container_name="oodikone-$database"
local database_name
database_name="$(postgres_database_name "$database")"
local dump_file="$LOCAL_DUMP_DIR/$database.sql"
infomsg "Dumping $database from container $container_name"
# Dump to a temporary file first, so that a failed dump doesn't overwrite the previous one
docker exec -i "$container_name" pg_dump -Fp -U postgres -d "$database_name" > "$dump_file.tmp"
mv "$dump_file.tmp" "$dump_file"
cp --remove-destination "$dump_file" "$TEST_DUMP_DIR/$database.sql"
successmsg "Dumped $database to $dump_file"
}
resolve_databases "$@"
make_dump_dirs
for database in "${selected_databases[@]}"; do
dump_database "$database"
done
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Shared configuration and helpers for the anonyymioodi database scripts.
# Meant to be sourced by the other scripts in this folder, not run on its own.
# The configuration below is used by the sourcing scripts, not by this file
# shellcheck disable=SC2034
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
source "$PROJECT_ROOT"/scripts/utils.sh
# Dumps taken from running docker containers
LOCAL_DUMP_DIR="$PROJECT_ROOT/.databasedumps/local_containers"
# Dumps downloaded from s3
S3_DUMP_DIR="$PROJECT_ROOT/.databasedumps/s3"
# Where db.Dockerfile copies the .sql files from when an image is built
TEST_DUMP_DIR="$PROJECT_ROOT/.databasedumps/test"
# Used as a fallback when ACCESS_KEY and SECRET_KEY aren't set in the environment
S3_CONFIG_FILE=~/.s3cfg
S3_HOST="s3.datacloud.helsinki.fi"
S3_BUCKET="s3://oodikone"
# Dumps live under a prefix of their own inside the bucket
S3_TARGET="$S3_BUCKET/test-dbs"
# Dumps older than this are pruned from s3, except for the newest dump of each database
S3_DUMP_RETENTION_DAYS=30
REGISTRY="registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi"
# Databases, named after their docker compose services
DATABASES=("user-db" "kone-db" "sis-db" "sis-importer-db" "jami-db")
# Set by resolve_databases, read by the scripts sourcing this file
selected_databases=()
# The name of the postgres database inside the container. It matches the service
# name for every database except jami-db.
postgres_database_name() {
local database="$1"
if [[ "$database" == "jami-db" ]]; then
echo "postgres"
else
echo "$database"
fi
}
# Validates the given database names and expands "all" to every database.
# The result is set to the global array $selected_databases, because dying
# inside a subshell wouldn't stop the calling script.
resolve_databases() {
selected_databases=()
if [[ $# -eq 0 ]]; then
die "No database given, expected one or more of: ${DATABASES[*]} all"
fi
for argument in "$@"; do
if [[ "$argument" == "all" ]]; then
selected_databases=("${DATABASES[@]}")
return
fi
# Check if given databases are correct ones
local known=false
for database in "${DATABASES[@]}"; do
if [[ "$argument" == "$database" ]]; then known=true; fi
done
$known || die "Unknown database: $argument. Expected one or more of: ${DATABASES[*]} all"
selected_databases+=("$argument")
done
}
# Dumps in s3 are named after the branch they were uploaded from, so the branch
# name has to be usable as a part of a file name. E.g. feature/foo -> feature-foo.
# feature/foo would result in a new bucket "feature" with a sub-bucket "foo"
sanitize_branch_name() {
echo "${1//[^A-Za-z0-9._-]/-}"
}
current_branch_name() {
local branch
branch="$(git rev-parse --abbrev-ref HEAD)"
if [[ "$branch" == "HEAD" ]]; then # Detached head, use the commit instead
branch="$(git rev-parse --short HEAD)"
fi
sanitize_branch_name "$branch"
}
has_s3_env_credentials() {
[[ -n "${ACCESS_KEY-}" && -n "${SECRET_KEY-}" ]]
}
# Credentials come from ACCESS_KEY and SECRET_KEY when both are set, and from
# $S3_CONFIG_FILE when neither is
require_s3_config() {
command -v s3cmd > /dev/null || die "s3cmd not found, please install it first"
if [[ -n "${ACCESS_KEY-}" || -n "${SECRET_KEY-}" ]]; then
has_s3_env_credentials || die "Only one of ACCESS_KEY and SECRET_KEY is set, set both or neither"
return
fi
[[ -f "$S3_CONFIG_FILE" ]] || die "Set ACCESS_KEY and SECRET_KEY, or add $S3_CONFIG_FILE. The \".s3cfg\" config file can be found on toska/dokumentaatio repo"
}
s3_env_config() {
cat <<EOF
[default]
access_key = $ACCESS_KEY
secret_key = $SECRET_KEY
host_base = $S3_HOST
host_bucket = $S3_HOST
use_https = True
check_ssl_certificate = True
EOF
}
# Runs s3cmd with the credentials chosen by require_s3_config. The environment
# credentials are passed through process substitution, so that they aren't
# written to disk or visible in the process list.
s3() {
if has_s3_env_credentials; then
s3cmd -c <(s3_env_config) "$@"
else
s3cmd -c "$S3_CONFIG_FILE" "$@"
fi
}
# The dump directories are created lazily by the scripts that write into them
make_dump_dirs() {
mkdir -p "$LOCAL_DUMP_DIR" "$S3_DUMP_DIR" "$TEST_DUMP_DIR"
}
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# Front end for the test database scripts in this folder. Run it without
# arguments for an interactive menu, or give it an action and a database to run
# it directly, e.g. in CI.
#
# Each action is just a call to one of the scripts next to this one, so they can
# also be used on their own.
set -euo pipefail
# Here BASH_SOURCE[0] is used instead of $0 because it works
# consistently when ran normally and when sourced
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR"/lib/common.sh
ACTIONS=("download" "dump" "upload" "push" "prune" "release" "rebuild")
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") [<action> <database>... | all]
Runs without arguments for an interactive menu.
Actions:
download Download the newest dumps from s3
dump Dump the databases from the running docker containers
upload Upload the current dumps to s3, then prune old dumps
push Build images from the current dumps and push them to the registry
prune Delete dumps older than $S3_DUMP_RETENTION_DAYS days from s3, keeping the newest one
release Dump from the running containers, then upload, prune and push (shorthand)
rebuild Download the dumps from s3, then push (shorthand)
Databases: ${DATABASES[*]} all
EOF
}
# === Actions ===
download_dump() {
"$SCRIPT_DIR"/download_dump_from_s3.sh "$1"
}
dump_database() {
"$SCRIPT_DIR"/dump_local_db.sh "$1"
}
upload_dump() {
"$SCRIPT_DIR"/upload_dump_to_s3.sh "$1"
}
prune_dumps() {
"$SCRIPT_DIR"/prune_s3_dumps.sh "$1"
}
push_image() {
"$SCRIPT_DIR"/build_and_push_image.sh "$1"
}
run_action() {
local action="$1"
local database="$2"
case "$action" in
download) download_dump "$database" ;;
dump) dump_database "$database" ;;
upload)
upload_dump "$database"
prune_dumps "$database"
;;
push) push_image "$database" ;;
prune) prune_dumps "$database" ;;
release)
dump_database "$database"
upload_dump "$database"
prune_dumps "$database"
push_image "$database"
;;
rebuild)
download_dump "$database"
push_image "$database"
;;
*)
usage
die "Unknown action: $action"
;;
esac
}
run_action_for_all() {
local action="$1"
shift
# Check if command was a proper command
local known_action=false
for known in "${ACTIONS[@]}"; do
if [[ "$action" == "$known" ]]; then known_action=true; fi
done
if ! $known_action; then
usage
die "Unknown action: $action"
fi
resolve_databases "$@"
for database in "${selected_databases[@]}"; do
run_action "$action" "$database"
done
successmsg "Action $action done for: ${selected_databases[*]}"
}
# === Interactive menu ===
ask_and_run() {
local action database
PS3="Select action: "
select action in "${ACTIONS[@]}" "quit"; do
[[ -n "${action-}" ]] || continue
if [[ "$action" == "quit" ]]; then exit 0; fi
break
done
PS3="Select database: "
select database in "${DATABASES[@]}" "all"; do
[[ -n "${database-}" ]] || continue
break
done
run_action_for_all "$action" "$database"
}
# === Run ===
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
usage
exit 0
fi
if [[ $# -eq 0 ]]; then
while true; do
ask_and_run
echo
done
fi
if [[ $# -lt 2 ]]; then
usage
die "Both an action and a database are required"
fi
run_action_for_all "$@"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Delete old database dumps from s3. Dumps older than the retention period are
# removed, but the newest master dump of each database is always kept, so that a
# database which hasn't changed in a while can still be downloaded. Dumps from
# other branches aren't kept, so that old branches don't accumulate dumps.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR"/lib/common.sh
# The newest dump of each database from this branch is never pruned
MASTER_BRANCH="master"
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") [-n|--dry-run] [-d|--days <days>] <database>... | all
Deletes the dumps in $S3_TARGET older than the given number of days
(default $S3_DUMP_RETENTION_DAYS). The newest $MASTER_BRANCH dump of each database is
always kept, or the newest dump of any branch if the database has no $MASTER_BRANCH dumps.
Databases: ${DATABASES[*]}
Options:
-n, --dry-run Print the dumps that would be deleted instead of deleting them
-d, --days <days> Delete dumps older than this many days
-h, --help Show this help
EOF
}
dry_run=false
days="$S3_DUMP_RETENTION_DAYS"
arguments=()
while [[ $# -gt 0 ]]; do
case "$1" in
-n | --dry-run)
dry_run=true
shift
;;
-d | --days)
days="${2-}"
[[ "$days" =~ ^[0-9]+$ ]] || {
usage
die "Expected a number of days for $1, got: $days"
}
shift 2
;;
-h | --help)
usage
exit 0
;;
-*)
usage
die "Unknown option: $1"
;;
*)
arguments+=("$1")
shift
;;
esac
done
if [[ ${#arguments[@]} -eq 0 ]]; then
usage
die "No database given"
fi
prune_database() {
local database="$1"
local deleted=0
# s3cmd ls prints "<date> <time> <size> <object>", so sorting by the first two
# columns in reverse puts the newest dump first
local dumps
dumps="$(s3 ls "$S3_TARGET/$database-" | sort -rk1,2)"
# Find the newest master dump or in the case of it missing, the newest dump in general
local kept
kept="$(awk -v pattern="/$database-$MASTER_BRANCH-[0-9]+-[0-9]+[.]sql$" '$4 ~ pattern { print $4; exit }' <<< "$dumps")"
[[ -n "$kept" ]] || kept="$(awk 'NR == 1 { print $4 }' <<< "$dumps")"
# Remove any dumps older than the dump retention date (or -d)
# except for master for which at least one dump is saved
while read -r date time _ dump; do
[[ -n "$dump" && "$dump" != "$kept" && "$date $time" < "$cutoff" ]] || continue
if [[ "$dry_run" == true ]]; then
infomsg "Dry run, would delete $dump"
else
s3 del "$dump"
fi
deleted=$((deleted + 1))
done <<< "$dumps"
if [[ "$dry_run" == true ]]; then
successmsg "Would have pruned $deleted dump(s) of $database older than $days days"
else
successmsg "Pruned $deleted dump(s) of $database older than $days days"
fi
}
resolve_databases "${arguments[@]}"
require_s3_config
cutoff="$(date -u -d "-$days days" '+%Y-%m-%d %H:%M')"
for database in "${selected_databases[@]}"; do
prune_database "$database"
done
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Upload database dumps from .databasedumps/test to s3, naming the objects
# <database>-<git_branch>-<datetime>.sql so that dumps don't overwrite each other.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR"/lib/common.sh
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") [-n|--dry-run] <database>... | all
Uploads the dumps in .databasedumps/test to $S3_TARGET
as <database>-<git_branch>-<datetime>.sql. Run dump_local_db.sh first to
create the dumps.
Databases: ${DATABASES[*]}
Options:
-n, --dry-run Print the s3cmd command instead of running it
-h, --help Show this help
EOF
}
dry_run=false
arguments=()
while [[ $# -gt 0 ]]; do
case "$1" in
-n | --dry-run)
dry_run=true
shift
;;
-h | --help)
usage
exit 0
;;
-*)
usage
die "Unknown option: $1"
;;
*)
arguments+=("$1")
shift
;;
esac
done
if [[ ${#arguments[@]} -eq 0 ]]; then
usage
die "No database given"
fi
upload_database() {
local database="$1"
local dump_file="$TEST_DUMP_DIR/$database.sql"
[[ -f "$dump_file" ]] || die "No dump found at $dump_file, run dump_local_db.sh first"
[[ -s "$dump_file" ]] || die "Dump is empty: $dump_file"
local datetime
datetime="$(date +%Y%m%d-%H%M%S)"
local target="$S3_TARGET/$database-$branch-$datetime.sql"
if [[ "$dry_run" == true ]]; then
infomsg "Dry run, would run: s3cmd put $dump_file $target"
return
fi
infomsg "Pushing $dump_file to $target"
s3 put "$dump_file" "$target"
successmsg "Uploaded $dump_file to $target"
}
resolve_databases "${arguments[@]}"
require_s3_config
if [[ "$dry_run" == false ]]; then
"$SCRIPT_DIR"/create_s3_bucket.sh
fi
branch="$(current_branch_name)"
for database in "${selected_databases[@]}"; do
upload_database "$database"
done
-29
View File
@@ -1,29 +0,0 @@
/* eslint-disable import-x/no-extraneous-dependencies */
/* eslint-disable import-x/no-default-export */
const { defineConfig } = require('cypress')
module.exports = defineConfig({
projectId: 'c3jsph',
// Fixes Github <-> Cypress permission errors in CI
downloadsFolder: '/tmp/cypress/downloads',
screenshotsFolder: '/tmp/cypress/screenshots',
videosFolder: '/tmp/cypress/videos',
video: true,
defaultCommandTimeout: 30000,
requestTimeout: 30000,
videoCompression: false,
viewportWidth: 1920,
viewportHeight: 1080,
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
require('./cypress/plugins/index.js')(on, config)
return config
},
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
},
})
-180
View File
@@ -1,180 +0,0 @@
/// <reference types="cypress" />
const openCompletedCoursesModal = () => {
cy.contains('button', 'Search completed courses of students').click()
}
const hasLanded = () => {
cy.contains('Search completed courses')
cy.contains(
'Here you can search by a list of student and course numbers to see whether students have completed certain courses yet'
)
}
const openSearch = () => {
cy.init('/completedcoursessearch')
hasLanded()
}
const selectSavedCourselist = name => {
cy.cs('history-search').click()
cy.cs('history-search').type(name)
cy.cs('history-search').type('{downarrow}')
cy.cs('history-search').type('{enter}')
}
const createCourseList = (courseCodes, courseListName) => {
openSearch()
openCompletedCoursesModal()
cy.cs('course-list-input').type(courseCodes.join('\n'))
cy.cs('search-name').type(courseListName)
cy.cs('save-courselist').click()
}
const generateCourseListName = () => `TEST-course-list-${new Date().getTime()}`
const deleteAllPreviousSearches = () => {
cy.cs('history-search').click()
cy.get('.MuiAutocomplete-popper li').then($options => {
if ($options.length > 0) {
cy.wrap($options).each(($option, index, $list) => {
cy.wrap($option).click()
cy.cs('delete-courselist').click()
if (index < $list.length - 1) {
cy.cs('history-search').click()
}
})
} else {
cy.contains('You have no previous searches.')
}
})
}
describe('When search modal is opened', () => {
after(() => {
cy.init('/completedcoursessearch')
hasLanded()
cy.url().should('include', '/completedcoursessearch')
cy.contains('Search completed courses of students')
cy.cs('open-completed-courses-modal-button').click()
})
it('Modal opens correctly', () => {
cy.init('/completedcoursessearch')
hasLanded()
openCompletedCoursesModal()
cy.contains('Search completed courses of students')
cy.contains('Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.')
cy.contains('Insert one or more courses, separated by a space, a newline, a comma, or a semicolon.')
cy.contains('Insert name for this course list if you wish to save it')
})
it('Modal gets the correct course codes and student numbers from the URL', () => {
cy.init('/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144')
hasLanded()
openCompletedCoursesModal()
cy.cs('student-no-input').contains('433237, 457144')
cy.cs('course-list-input').contains('TKT10001, TKT10002')
})
describe('When a search is executed with invalid input', () => {
it('Returns empty table and notification', () => {
cy.init('/completedcoursessearch')
hasLanded()
openCompletedCoursesModal()
cy.cs('student-no-input').type('1')
cy.cs('course-list-input').type('1')
cy.cs('completed-courses-search-button').click()
cy.cs('rights-notification').should(
'contain.text',
'The information for the following students could not be displayed'
)
cy.cs('rights-notification').contains('1')
cy.cs('completed-courses-table-div').contains('Student number')
cy.cs('completed-courses-table-div').contains('1').should('not.exist')
})
})
describe('When a search with correct data is executed', () => {
beforeEach(() => {
cy.init('/completedcoursessearch')
hasLanded()
openCompletedCoursesModal()
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
cy.cs('student-no-input').type(studentSet1.join('\n'))
cy.cs('course-list-input').type(coursesSet1.join('\n'))
cy.cs('completed-courses-search-button').click()
})
})
it('Pushes the query to url', () => {
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
cy.url().should(
'include',
`/completedcoursessearch?courseList=${coursesSet1.join('&courseList=')}&studentList=${studentSet1.join('&studentList=')}`
)
})
})
it('Finds correct students and courses', () => {
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
for (const studentNumber of studentSet1) {
cy.cs('completed-courses-table-div').contains(studentNumber)
}
for (const courseCode of coursesSet1) {
cy.cs('completed-courses-table-div').contains(courseCode)
}
})
})
})
})
describe('Courselist saving-related functions work', () => {
beforeEach(() => {
openSearch()
openCompletedCoursesModal()
cy.get('body').then($body => {
if ($body.find('[data-cy=history-search]').length > 0) {
deleteAllPreviousSearches()
} else {
cy.contains('You have no previous searches.')
}
})
})
it('Course list can be saved', () => {
const courseList = generateCourseListName()
const courses = ['CSM14204', 'TKT10004']
createCourseList(courses, courseList)
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.cs('course-list-input').contains(courses.join(', '))
})
// TODO: This will be re-enabled when migrating to playwright
it.skip('Course list can be deleted', () => {
const courseList = generateCourseListName()
createCourseList(['CSM14204', 'TKT10004'], courseList)
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.cs('delete-courselist').click()
cy.contains('You have no previous searches.')
})
it('Course list can be updated', () => {
const courseList = generateCourseListName()
createCourseList(['CSM14204', 'TKT10004'], courseList)
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.cs('course-list-input').type(',TKT10001')
cy.cs('save-courselist').click()
openSearch()
openCompletedCoursesModal()
selectSavedCourselist(courseList)
cy.cs('course-list-input').contains('CSM14204, TKT10004, TKT10001')
})
})
File diff suppressed because it is too large Load Diff
-192
View File
@@ -1,192 +0,0 @@
/// <reference types="cypress" />
const fillName = () => {
const name = `TEST-${new Date().getTime()}`
cy.contains('Insert a name for this custom population if you wish to save it')
cy.cs('custom-population-name-input').type(name)
return name
}
const save = () => {
cy.get('button').contains('Save').click()
}
const selectSavedPopulation = name => {
cy.cs('history-search').type(`${name}{enter}`)
}
const deleteAllSearches = () => {
cy.cs('history-search').click()
cy.get('.MuiAutocomplete-popper > .MuiPaper-root')
.find('li[role=option]')
.then($lis => {
const labels = [...$lis].map(li => li.textContent?.trim() || '')
labels.forEach((label, index) => {
if (index !== 0) cy.cs('history-search').click()
cy.contains('.MuiAutocomplete-popper li[role=option]', label).click()
cy.contains('button', 'Delete').click()
})
})
}
const fillForm = (content, separator) => {
cy.cs('student-number-input').type(content.join(separator))
}
const search = () => {
cy.cs('search-button').click()
}
const searchFor = (studentnumbers, separator) => {
fillForm(studentnumbers, separator)
search()
}
const hasLanded = () => {
cy.contains('Credit accumulation')
cy.contains('Programme distribution')
cy.contains('Courses of population')
cy.contains('Students')
}
const containsAmountOfStudents = (amount = 0) => {
cy.contains(`Credit accumulation (for ${amount} students)`)
}
const containsSpecificStudents = (studentnumbers = []) => {
cy.contains(`Students (${studentnumbers.length})`)
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains(`Students (${studentnumbers.length})`).click()
})
studentnumbers.forEach(s => cy.contains(s))
}
const checkRightsNotification = studentNumbers => {
cy.cs('rights-notification').within(() => {
cy.contains(
'The following students information could not be displayed. This could be either because they do not exist, or you do not have the right to view their information.'
)
cy.get('ul').within(() => {
studentNumbers.forEach(number => cy.get('li').contains(number))
})
})
}
describe('Custom population tests', () => {
const nonExistentStudentNumbers = ['123', 'X', '-']
beforeEach(() => {
cy.init('/custompopulation')
cy.url().should('include', '/custompopulation')
cy.contains('Custom population')
})
after(() => {
cy.visit('/custompopulation')
cy.url().should('include', '/custompopulation')
cy.contains('Custom population')
deleteAllSearches()
})
describe('Custom population searching', () => {
it('Finds a proper population', () => {
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1: students }) => {
searchFor(students, '\n')
hasLanded()
containsAmountOfStudents(students.length)
containsSpecificStudents(students)
})
})
it("Doesn't return non-existing students", () => {
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1: students }) => {
searchFor([...students, ...nonExistentStudentNumbers], ' ')
hasLanded()
containsAmountOfStudents(students.length)
containsSpecificStudents(students)
checkRightsNotification(nonExistentStudentNumbers)
})
})
it("Doesn't find empty custom population", () => {
searchFor(nonExistentStudentNumbers, ',')
checkRightsNotification(nonExistentStudentNumbers)
cy.contains('Credit accumulation').should('not.exist')
cy.contains('Programme distribution').should('not.exist')
cy.contains('Courses of population').should('not.exist')
cy.get('button').contains('Back to search form')
})
it("Doesn't return students user has no right to", () => {
// These students exist in the database, but the user doesn't have the right to view them
const studentsForEduBachStudents = ['014990067', '013069465', '014853890']
// Two semicolons on purpose, the page should be able to handle it
searchFor(studentsForEduBachStudents, ';;')
checkRightsNotification(studentsForEduBachStudents)
cy.contains('Credit accumulation').should('not.exist')
cy.contains('Programme distribution').should('not.exist')
cy.contains('Courses of population').should('not.exist')
cy.get('button').contains('Back to search form')
})
})
describe('Custom population search saving', () => {
it('Saves a custom population search', { retries: 2 }, () => {
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1: students }) => {
cy.contains(
'Insert student numbers to use for the population. Each student number needs to be separated with a comma, a semicolon, a space, or a line break.'
)
const name = fillName()
// A comma and a newline on purpose, the page should be able to handle it
fillForm(students, ',\n')
save()
// Round 1
selectSavedPopulation(name)
search()
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students.length)
containsSpecificStudents(students)
// Round 2
cy.visit('/custompopulation')
cy.contains('Custom population')
selectSavedPopulation(name)
search()
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students.length)
containsSpecificStudents(students)
})
})
it('Updates a custom population search', () => {
cy.fixture('customPopulations').then(
({ studentNumbersForCSStudentsSet1: students1, studentNumbersForCSStudentsSet2: students2 }) => {
const name = fillName()
fillForm(students1, ' ')
save()
selectSavedPopulation(name)
search()
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students1.length)
containsSpecificStudents(students1)
cy.contains('Back to search form').click()
selectSavedPopulation(name)
fillForm(['\n', ...students2], ', ')
save()
cy.visit('/custompopulation')
cy.contains('Custom population')
selectSavedPopulation(name)
search()
cy.contains(`Custom population: ${name}`)
containsAmountOfStudents(students1.length + students2.length)
containsSpecificStudents([...students1, ...students2])
}
)
})
})
})
-922
View File
@@ -1,922 +0,0 @@
/// <reference types="cypress" />
const getEmptyYears = isAcademicYear => {
const today = new Date()
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
const years = []
for (let year = latestYear; year >= 2024; year--) {
if (isAcademicYear) {
years.push(`${year} - ${year + 1}`)
} else {
years.push(year)
}
}
return years
}
const tagName = `tag-${new Date().getTime()}`
const selectYear = year => {
// ? Testing the MUI X DatePicker seems very difficult. It does not detect data-cy and the tests fail in CI/CD
cy.contains('Associated start year (optional)').click({ force: true })
cy.get('[placeholder=YYYY]').type(year)
}
const deleteTag = tagName => {
cy.cs(`delete-tag-${tagName}-button`).click()
cy.contains('Delete tag')
cy.contains('Are you sure you want to delete tag')
cy.cs('confirm-delete-tag-button').click()
}
describe('Degree programme overview', () => {
describe('Degree programme selector', () => {
it('Degree programme search filter', () => {
cy.init('/study-programme', 'admin')
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
cy.contains('Matemaattisten tieteiden kandiohjelma').should('exist')
cy.cs('study-programme-filter').type('Tietojenkäsittelytieteen')
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
cy.contains('Matemaattisten tieteiden kandiohjelma').should('not.exist')
})
})
describe('Basic information tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
cy.cs('year-toggle').click() // NOTE: Tests are written for calendar years
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
it('Basic information tab loads', () => {
cy.cs('students-of-the-study-programme-section')
cy.cs('credits-produced-by-the-study-programme-section')
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
cy.cs('programmes-before-or-after-section')
cy.cs('average-graduation-times-section')
})
it('Basic information contains correct students', () => {
const years = getEmptyYears()
const tableContents = [
// [Year, Started studying, Accepted, Graduated, Cancelled, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2023, 8, 8, 26, 2, 0, 0],
[2022, 25, 26, 47, 0, 1, 3],
[2021, 29, 32, 48, 1, 0, 2],
[2020, 26, 27, 12, 0, 1, 3],
[2019, 28, 34, 1, 0, 0, 1],
[2018, 40, 45, 0, 0, 0, 1],
[2017, 41, 47, 0, 0, 0, 0],
]
cy.checkTableStats(tableContents, 'students-of-the-study-programme')
})
it('Basic information contains correct credits', () => {
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 0, 0, 0, 222, 0],
[2022, 3235, 3205, 0, 0, 30, 0, 0, 209, 0],
[2021, 5133, 5108, 0, 0, 25, 0, 0, 428, 25],
[2020, 5801, 5796, 0, 0, 5, 0, 0, 94, 0],
[2019, 5305, 5305, 0, 0, 0, 0, 0, 162, 0],
[2018, 3442, 3432, 0, 0, 10, 0, 0, 21, 0],
[2017, 1211, 1211, 0, 0, 0, 0, 0, 189, 0],
]
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
})
it('Basic information contains correct thesis writers and graduates', () => {
const years = getEmptyYears()
const tableContents = [
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 26, 5],
[2022, 47, 13],
[2021, 48, 76],
[2020, 12, 23],
[2019, 1, 19],
[2018, 0, 0],
[2017, 0, 1],
]
cy.checkTableStats(tableContents, 'graduated-and-graduations-of-the-programme')
})
it('Special study rights can be excluded and basic data changes accordingly', () => {
cy.cs('study-right-toggle').click()
const years = getEmptyYears()
const studentTableContents = [
// [Year, Started studying, Accepted, Graduated]
...years.map(year => [year, 0, 0, 0]),
[2023, 8, 8, 24],
[2022, 25, 26, 43],
[2021, 29, 32, 47],
[2020, 26, 27, 11],
[2019, 28, 34, 1],
[2018, 40, 45, 0],
[2017, 41, 47, 0],
]
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
const graduatedTableContents = [
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 24, 5],
[2022, 43, 12],
[2021, 47, 69],
[2020, 11, 22],
[2019, 1, 18],
[2018, 0, 0],
[2017, 0, 1],
]
cy.checkTableStats(graduatedTableContents, 'graduated-and-graduations-of-the-programme')
})
it('Year can be changed to academic year, and data changes accordingly', () => {
cy.cs('year-toggle').click()
const isAcademicYear = true
const years = getEmptyYears(isAcademicYear)
const studentTableContents = [
// [Year, Started studying, Accepted, Graduated, Cancelled, Transferred away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 4, 1, 0, 0],
['2022 - 2023', 25, 26, 35, 1, 0, 0],
['2021 - 2022', 29, 32, 58, 1, 1, 5],
['2020 - 2021', 26, 27, 30, 0, 0, 3],
['2019 - 2020', 28, 34, 7, 0, 1, 1],
['2018 - 2019', 40, 45, 0, 0, 0, 1],
['2017 - 2018', 41, 47, 0, 0, 0, 0],
]
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
const creditTableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 160, 160, 0, 0, 0, 0, 0, 67, 0],
['2022 - 2023', 2725, 2720, 0, 0, 5, 0, 0, 337, 0],
['2021 - 2022', 4092, 4042, 0, 0, 50, 0, 0, 198, 0],
['2020 - 2021', 5420, 5415, 0, 0, 5, 0, 0, 321, 25],
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 0, 101, 0],
['2018 - 2019', 4856, 4851, 0, 0, 5, 0, 0, 107, 0],
['2017 - 2018', 2350, 2345, 0, 0, 5, 0, 0, 26, 0],
]
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
cy.cs('year-toggle').click()
})
it('Basic information graphs render', () => {
cy.cs('students-of-the-study-programme-line-graph-section')
.should('contain', 'Started studying')
.should('contain', 'Accepted')
.should('contain', 'Graduated')
.should('contain', 'Transferred away')
.should('contain', 'Transferred to')
cy.cs('credits-produced-by-the-study-programme-stacked-bar-chart-section')
.should('contain', 'Degree students')
.should('contain', 'Transferred')
.should('contain', 5796)
.should('contain', 428)
cy.cs('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
.should('contain', 'Graduated students')
.should('contain', 'Wrote thesis')
.should('contain', 47)
.should('contain', 76)
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-median').click()
})
cy.cs('unset-median-bar-chart').within(() => {
cy.contains('47 graduated').should('be.visible')
cy.contains('47 graduated').hover()
})
cy.get('.grad-vals').contains('47 students graduated in year 2022').should('be.visible')
cy.contains('median study time: 6 semesters')
cy.contains('24 graduated on time')
cy.contains('12 graduated max year overtime')
cy.contains('11 graduated over year late')
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
.should('contain', 'Datatieteen maisteriohjelma')
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
.should('contain', 32)
.should('contain', 21)
.should('contain', 17)
})
})
describe('Graduation times of master programmes', () => {
it('are split into three graphs', () => {
cy.init('/study-programme')
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
cy.cs('year-toggle').click() // Tests are written for calendar year
cy.cs('unset-breakdown-bar-chart')
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-average').click()
})
cy.cs('unset-graduation-times-section')
.eq(1)
.within(() => {
cy.contains('Master study right')
cy.contains('2 graduated').should('be.visible')
cy.contains('2 graduated').hover()
})
cy.get('.grad-vals').contains('2 students graduated in year 2021').should('be.visible')
cy.contains('average study time: 4.5 semesters')
cy.contains('1 graduated on time')
cy.contains('1 graduated max year overtime')
cy.contains('0 graduated over year late')
cy.cs('unset-graduation-times-section')
.eq(0)
.within(() => {
cy.contains('Bachelor + master study right')
cy.contains('11 graduated').should('be.visible')
cy.contains('11 graduated').hover()
})
cy.get('.grad-vals').contains('11 students graduated in year 2023').should('be.visible')
cy.contains('average study time: 11.18 semesters')
cy.contains('4 graduated on time')
cy.contains('6 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
describe('Study tracks and class statistics tab works for basic user', () => {
beforeEach(() => {
cy.intercept(
'GET',
'api/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
).as('stQuery')
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('StudyTracksAndClassStatisticsTab').click()
})
describe('Info boxes', () => {
it('Study track overview section', () => {
cy.cs('study-track-overview-info-box-button').click()
cy.cs('study-track-overview-info-box-content').contains('Opiskelijat, joiden')
})
it('Progress of students section', () => {
cy.cs('progress-of-students-info-box-button').click()
cy.cs('progress-of-students-info-box-content').contains('Kuvaa koulutusohjelmassa')
})
// HACK: Do this properly
it('Average graduation times section', () => {
cy.wait(500)
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-content').contains('Yksittäinen palkki')
})
})
it('All sections are visible', () => {
cy.cs('study-track-selector-section')
cy.cs('study-track-overview-section')
cy.cs('progress-of-students-section')
cy.cs('average-graduation-times-section')
})
it('Students of the degree programme are shown correctly', () => {
const tableContents = [
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Has recent attainments, Men, Women, Other/Unknown, Finland, Other]
['2023 - 2024', 8, 8, 0, 0, 8, 0, 0, 5, 3, 0, 8, 1],
['2022 - 2023', 26, 25, 0, 0, 24, 2, 0, 19, 7, 0, 25, 1],
['2021 - 2022', 37, 29, 0, 0, 33, 4, 0, 29, 8, 0, 35, 4],
['2020 - 2021', 30, 26, 0, 0, 11, 19, 0, 15, 15, 0, 29, 3],
['2019 - 2020', 35, 28, 0, 0, 8, 27, 0, 22, 13, 0, 35, 2],
['2018 - 2019', 46, 40, 0, 0, 6, 40, 0, 26, 20, 0, 45, 1],
['2017 - 2018', 47, 41, 0, 0, 5, 42, 0, 31, 16, 0, 47, 0],
['Total', 229, 197, 0, 0, 95, 134, 0, 147, 82, 0, 224, 12],
]
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
})
it.skip('Years in the students table can be expanded and study track data will be shown', () => {
// TODO: Fix this test
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
cy.get('tbody tr.header-row')
.eq(3)
.within(() => {
cy.get('td i.angle.right.icon').click()
})
const dataForStudyTracks = [
['Ekonometria, MAT-EKO', 2, 2, 0, 0, 0, 2, 1, 1, 0, 2, 0],
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
['Tietojenkäsittelyteoria, MAT-TIE', 2, 1, 0, 0, 1, 1, 2, 0, 0, 2, 0],
['Tilastotiede, MAT-TIL', 4, 4, 0, 0, 0, 4, 1, 3, 0, 4, 0],
]
dataForStudyTracks.forEach((data, index) => {
cy.get('tbody tr.regular-row')
.eq(index)
.within(() => {
cy.get('td').each((cell, i) => {
cy.wrap(cell).contains(data[i])
})
})
})
cy.get('tbody tr.header-row')
.eq(3)
.within(() => {
cy.get('td i.angle.down.icon').click()
})
cy.get('tbody tr.regular-row').should('not.exist')
})
})
describe('Population link button works for', () => {
it('a single year', () => {
cy.cs('2023-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2023 - 2024, 8 students')
})
it('total', () => {
cy.cs('total-population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2017 - 2026, 227 students')
})
it('Links to class statistics page with study track info included work', () => {
cy.cs('show-study-tracks-button').first().click()
cy.contains('td', 'Matematiikka (MAT-MAT)').within(() => {
cy.get('a').click()
})
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class of 2022 - 2023, 26 students')
cy.contains('Showing 3 out of 26 students')
})
})
it('Student progress data is shown correctly', () => {
const years = getEmptyYears(true)
const tableContents = [
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits, Graduated]
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0, 0],
['2022 - 2023', 26, 9, 8, 4, 3, 0, 0, 0, 2],
['2021 - 2022', 37, 8, 4, 9, 8, 4, 0, 0, 4],
['2020 - 2021', 30, 2, 1, 2, 5, 0, 0, 1, 19],
['2019 - 2020', 35, 1, 0, 2, 1, 0, 0, 4, 27],
['2018 - 2019', 46, 0, 1, 1, 2, 0, 0, 2, 40],
['2017 - 2018', 47, 0, 1, 3, 0, 0, 1, 0, 42],
['Total', 229, 28, 15, 21, 19, 4, 1, 7, 134],
]
cy.checkTableStats(tableContents, 'study-programme-progress')
})
it('Progress section', () => {
cy.cs('programme-progress-bar-chart-section').scrollIntoView()
cy.cs('programme-progress-bar-chart-section')
.should('contain', 'Less than 30 credits')
.should('contain', '30–60 credits')
.should('contain', 'At least 180 credits')
.should('contain', '58.5%') // The percentage for total graduated, to check that the graph renders
cy.cs('programme-progress-bar-chart-section').contains('58.5%').hover()
cy.contains('Graduated: 134')
})
// CI contains different data than locally
it.skip('Average graduation times section', () => {
cy.cs('average-graduation-times-section').scrollIntoView()
cy.cs('average-graduation-times-section').within(() => {
cy.cs('unset-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
})
cy.get('text').contains('16').hover()
cy.contains('On time: 16')
cy.cs('unset-graduation-times-section').within(() => {
cy.contains("Click a bar to view that year's study track level breakdown")
cy.get('text').contains('22').click()
})
cy.contains('Year 2019 - 2020 by start year')
cy.get('text').contains('13').hover()
cy.contains('Matematiikka')
cy.contains('MAT-MAT')
cy.contains('On time: 13')
})
cy.cs('graduation-time-toggle').click()
cy.cs('average-graduation-times-section').within(() => {
cy.contains('2020 - 2021')
cy.contains('19 graduated').hover()
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
cy.contains('median study time: 6 semesters')
cy.contains('16 graduated on time')
cy.contains('2 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
describe('Study track can be changed', () => {
beforeEach(() => {
cy.wait('@stQuery')
cy.contains('All students of the programme')
cy.cs('study-track-select').click()
cy.contains('Ekonometria')
cy.contains('Tietojenkäsittelyteoria')
cy.contains('Tilastotiede')
cy.contains('Matematiikka').click()
})
it('Students of the study track are shown correctly', () => {
cy.cs('study-track-overview-section').contains('Students of the study track MAT-MAT by starting year')
const tableContents = [
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Has recent attainment, Men, Women, Other/Unknown, Finland, Other]
['2022 - 2023', 3, 3, 0, 0, 1, 2, 0, 2, 1, 0, 3, 0],
['2021 - 2022', 4, 1, 0, 0, 1, 3, 0, 3, 1, 0, 4, 0],
['2020 - 2021', 13, 10, 0, 0, 1, 12, 0, 7, 6, 0, 12, 3],
['2019 - 2020', 17, 14, 0, 0, 0, 17, 0, 10, 7, 0, 17, 1],
['2018 - 2019', 24, 21, 0, 0, 2, 22, 0, 11, 13, 0, 24, 0],
['2017 - 2018', 28, 24, 0, 0, 1, 27, 0, 15, 13, 0, 28, 0],
['Total', 89, 73, 0, 0, 6, 83, 0, 48, 41, 0, 88, 4],
]
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
})
it('Links to class statistics page with study track info included work', () => {
cy.cs('2020-population-link-button').first().click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Studytrack MAT-MAT')
cy.contains('Class of 2020 - 2021, 30 students')
cy.contains('Showing 10 out of 30 students')
})
// FIXME: This feature was implemented. The data is no-good, so maybe reimplement this when it's clean.
it.skip('Info message about missing progress stats is displayed', () => {
cy.contains('Progress of students of the study track MAT-MAT by starting year')
cy.contains('Progress data is currently only available for all students of the degree programme.')
})
it.skip('Average graduation times are displayed correctly', () => {
cy.get("[data-cy='Section-averageGraduationTimesStudyTracks']")
cy.get("[data-cy='graduation-times-graph-breakdownBachelor']").within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.get('[aria-label="2020 - 2021, 9. On time."]').hover()
cy.contains('Graduated On time: 9 students')
})
cy.cs('graduation-time-toggle').click()
cy.cs('bachelor-median-time-bar-chart').within(() => {
cy.contains('Start year')
cy.contains('2020 - 2021')
cy.contains('12 graduated').hover()
cy.contains('From class of 2020 - 2021, 12/13 students have graduated')
cy.contains('median study time: 34 months')
cy.contains('9 graduated on time')
cy.contains('2 graduated max year overtime')
cy.contains('1 graduated over year late')
})
})
})
})
describe('Programme courses tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('ProgrammeCoursesTab').click()
cy.cs('by-credit-type-section')
cy.cs('by-credit-type-tab')
cy.cs('by-semester-tab')
})
describe('By credit type tab', () => {
beforeEach(() => {
cy.cs('show-credits-students-toggle').click()
cy.cs('year-toggle').click()
cy.get('thead').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('th').eq(3).click()
})
})
cy.get('tbody').within(() => {
cy.get('tr')
.eq(8)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT11004')
cy.get('td').eq(2).contains('Differentiaalilaskenta')
cy.get('td').eq(3).contains('1240')
})
})
})
it('time range selection works', () => {
cy.cs('from-year-select').click()
cy.cs('from-year-select-option-2021').click()
cy.cs('to-year-select').click()
cy.cs('to-year-select-option-2022').click()
cy.get('tbody').within(() => {
cy.get('tr')
.eq(9)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT21009')
cy.get('td').eq(2).contains('Kandidaatintutkielma')
cy.get('td').eq(3).contains('414')
})
})
})
it('year toggle works', () => {
cy.cs('year-toggle').click() // Default view now Academic years
cy.cs('from-year-select').click()
cy.cs('from-year-select-option-2017').click()
cy.cs('to-year-select').click()
cy.cs('to-year-select-option-2023').click()
cy.get('tbody').within(() => {
cy.get('tr')
.eq(5)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT11001')
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
cy.get('td').eq(3).contains('1265')
})
})
})
// TODO: Fix sorting / filters
it.skip('different sorting options work', () => {
cy.cs('CoursesSortableTable').within(() => {
// Course code
cy.get('th').eq(0).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('odgi-zef0')
cy.get('td').eq(1).contains('Työ- ja organisaatiopsykologian perusopinnot')
})
cy.get('th').eq(0).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT-yht')
cy.get('td').eq(1).contains('Muut opinnot')
})
// Course name
cy.get('th').eq(1).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT20003')
cy.get('td').eq(1).contains('Äidinkielen opinnot')
})
cy.get('th').eq(1).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT11008')
cy.get('td').eq(1).contains('Advanced calculus')
})
// Total credits
cy.get('th').eq(2).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT110')
cy.get('td').eq(1).contains('Matematiikka, perusopinnot')
})
cy.get('th').eq(2).click()
cy.get('tr')
.eq(1)
.within(() => {
cy.get('td').eq(0).contains('MAT20001')
cy.get('td').eq(1).contains('Kypsyysnäyte')
})
})
})
it("'Show credits/Show students' toggle works", () => {
cy.cs('year-toggle').click()
cy.wait(500) // So that the toggle has time to load
cy.get('thead tr')
.eq(0)
.within(() => {
cy.get('th').should('have.length', 6)
const headers = ['Type', 'Code', 'Name', 'Total credits', 'Credits produced by', 'Transferred credits']
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 7)
const headers = [
'Degree students',
'Open university (hetu)',
'Open university (no hetu)',
'Exchange students',
'Other universities',
'Separate studies',
'Other',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.cs('show-credits-students-toggle').click()
cy.get('thead tr')
.eq(0)
.within(() => {
cy.get('th').should('have.length', 7)
const headers = [
'Type',
'Code',
'Name',
'Total students',
'Breakdown of total',
'Breakdown of passed',
'Not included in total or passed',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.get('thead tr')
.eq(1)
.within(() => {
cy.get('th').should('have.length', 10)
const headers = [
'Passed',
'Not completed',
'Degree students',
'Open university students (with hetu)',
'Open university students (without hetu)',
'Exchange students',
'Other university students',
'Separate studies',
'Other students',
'Students with transferred credits',
]
headers.forEach((header, index) => {
cy.get('th').eq(index).contains(header)
})
})
cy.get('tbody').within(() => {
cy.get('tr')
.eq(5)
.within(() => {
cy.get('td').eq(0).contains('Course')
cy.get('td').eq(1).contains('MAT11001')
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
cy.get('td').eq(3).contains('272')
})
})
})
})
describe('By semester tab', () => {
beforeEach(() => {
cy.cs('by-semester-tab').click()
})
// FIXME: Flaky
it.skip('test', () => {
// TODO: Implement some real tests for this tab
cy.contains('From')
})
})
})
describe('Degree courses tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('DegreeCoursesTab').click()
})
it('Curriculum section', () => {
cy.cs('curriculum-section')
cy.contains('Select curriculum to edit:')
cy.cs('curriculum-picker').contains('2023–2026')
// TODO: Test changing the curriculum
})
describe('Credit criteria section', () => {
it('info box', () => {
cy.cs('credit-criteria-section')
cy.cs('credit-criteria-info-box-button').click()
cy.cs('credit-criteria-info-box-content').contains('Here you can change')
})
it.skip('changing the credit criteria works', () => {
// TODO: Test using the inputs
// TODO: Button status
// TODO: Test "previously set to _" text changing when saving
})
})
it('Degree course table', () => {
cy.cs('degree-course-table')
// TODO: Test clicking the arrow buttons
// TODO: Test toggling visibility for MODULES and COURSES
// TODO: Test changing criterion labels
})
})
describe('Tags tab works for basic user', () => {
beforeEach(() => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
cy.cs('TagsTab').click()
})
it('info box', () => {
cy.cs('create-new-tag-info-box-button').click()
cy.cs('create-new-tag-info-box-content').contains('Here you can create')
})
describe('Adding tags to populations and removing them works', () => {
beforeEach(() => {
cy.contains('This degree programme does not have any tags yet')
cy.cs('tag-name-text-field').type(tagName)
})
afterEach(() => {
deleteTag(tagName)
cy.contains('This degree programme does not have any tags yet')
})
it('can create and delete tags without start year', () => {
cy.cs('create-button').click()
cy.contains(tagName)
cy.contains('No associated start year')
})
it.skip('can create and delete tags with start year', () => {
// TODO: This test fails in the pipeline, but works locally. Investigate.
selectYear('2022')
cy.cs('create-button').click()
cy.contains(tagName)
cy.contains('Associated start year 2022')
})
it.skip('population link works', () => {
// TODO: This test fails in the pipeline, but works locally. Investigate.
selectYear('2022')
cy.cs('create-button').click()
cy.contains(tagName)
cy.cs('population-link-button').click()
cy.contains(`Tagged with: ${tagName}`)
cy.go('back')
})
it('can create personal tags', () => {
cy.cs('personal-tag-toggle').click()
cy.cs('create-button').click()
cy.contains(tagName)
cy.cs(`${tagName}-visibility-icon`)
})
})
describe('Adding tags to students and removing them works', () => {
const studentInput = '477806,478275; 478953 479239\n 480080'
const studentNumbers = studentInput.match(/[^\s,;]+/g)
it.skip('can add tags to students', () => {
// TODO: This test fails in the pipeline, but works locally. Investigate.
cy.cs('tag-name-text-field').type(tagName)
selectYear('2022')
cy.cs('create-button').click()
cy.contains(tagName)
cy.cs('add-students-button').click()
cy.cs('add-students-text-field').type(studentInput)
cy.cs('add-students-confirm-button').click()
cy.contains('Students added to tag')
cy.cs('population-link-button').click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2022 - 2023')
cy.contains(`Tagged with: ${tagName}`)
cy.contains('Students (5)')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Students (5)').click()
})
for (const studentNumber of studentNumbers) {
cy.contains(studentNumber)
}
for (const studentNumber of studentNumbers) {
cy.visit(`/students/${studentNumber}`)
cy.contains('Tags')
cy.contains(tagName)
}
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
cy.url().should('include', '/study-programme/KH50_001?tab=4')
deleteTag(tagName)
})
it('deleting a tag from tag view also removes it from students', () => {
cy.contains(tagName).should('not.exist')
for (const studentNumber of studentNumbers) {
cy.visit(`/students/${studentNumber}`)
cy.contains('Tags').should('not.exist')
cy.contains(tagName).should('not.exist')
}
})
})
})
describe('IAM user', () => {
beforeEach(() => {
cy.init('/study-programme', 'onlyiamrights')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
})
it.skip('year selector', () => {
// TODO: Implement this test
// Things to test:
// - Clicking the button works
// - Selecting a study track works
// - The button is disabled if only one year is selected
// - The slider is not visible if the user does not have correct programme rights
})
it('can access programme and correct tabs are visible', () => {
cy.contains('Basic information')
cy.contains('Study tracks and class statistics')
cy.contains('Update statistics').should('not.exist')
cy.contains('Degree courses').should('not.exist')
})
it('can access basic information', () => {
cy.cs('BasicInformationTab').click()
cy.cs('students-of-the-study-programme-section')
cy.cs('credits-produced-by-the-study-programme-section')
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
cy.cs('average-graduation-times-section')
cy.cs('programmes-before-or-after-section')
})
it('can access study tracks', () => {
cy.cs('StudyTracksAndClassStatisticsTab').click()
cy.cs('study-track-overview-section')
cy.cs('progress-of-students-section')
cy.cs('average-graduation-times-section')
})
it("doesn't see other tabs", () => {
cy.cs('DegreeCoursesTab').should('not.exist')
cy.cs('TagsTab').should('not.exist')
cy.cs('UpdateStatisticsTab').should('not.exist')
})
})
})
-287
View File
@@ -1,287 +0,0 @@
/// <reference types="cypress" />
const path = require('path')
const timestamp = new Date().toLocaleDateString('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' }) // YYYY-MM-DD, taking the local time zone into account
const downloadsFolder = Cypress.config('downloadsFolder')
describe('Faculty statistics', () => {
describe('Faculty list', () => {
beforeEach(() => {
cy.init('/faculties')
})
it('contains faculty names and faculty codes', () => {
cy.contains('Faculties')
cy.contains('Teologinen tiedekunta')
cy.contains('H10')
cy.contains('Oikeustieteellinen tiedekunta')
cy.contains('H20')
cy.contains('Lääketieteellinen tiedekunta')
cy.contains('H30')
cy.contains('Humanistinen tiedekunta')
cy.contains('H40')
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
cy.contains('H50')
cy.contains('Farmasian tiedekunta')
cy.contains('H55')
cy.contains('Bio- ja ympäristötieteellinen tiedekunta')
cy.contains('H57')
cy.contains('Kasvatustieteellinen tiedekunta')
cy.contains('H60')
cy.contains('Valtiotieteellinen tiedekunta')
cy.contains('H70')
cy.contains('Svenska social- och kommunalhögskolan')
cy.contains('H74')
cy.contains('Maatalous-metsätieteellinen tiedekunta')
cy.contains('H80')
cy.contains('Eläinlääketieteellinen tiedekunta')
cy.contains('H90')
})
it('contains a working link to faculty page', () => {
cy.contains('Faculties')
cy.contains('a', 'Teologinen tiedekunta').click()
cy.location('pathname').should('eq', '/faculties/hy-org-1000000580')
cy.contains('Teologinen tiedekunta')
cy.contains('H10')
})
})
describe('Basic information tab', () => {
describe('Admin user', () => {
beforeEach(() => {
cy.init('/faculties', 'admin')
cy.contains('a', 'Eläinlääketieteellinen tiedekunta').click()
})
it('Correct tabs are shown', () => {
cy.cs('faculty-tabs').should('contain', 'Basic information')
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
cy.cs('faculty-tabs').should('contain', 'Graduation times')
cy.cs('faculty-tabs').should('contain', 'Update statistics')
})
})
describe('Basic user', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
})
describe('Info boxes', () => {
it('Students of the faculty', () => {
cy.cs('students-of-the-faculty-info-box-button').click()
cy.cs('students-of-the-faculty-info-box-content').contains('Taulukon luvut on')
})
it('Graduated of the faculty', () => {
cy.cs('graduated-of-the-faculty-info-box-button').click()
cy.cs('graduated-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
})
it('Thesis writers of the faculty', () => {
cy.cs('thesis-writers-of-the-faculty-info-box-button').click()
cy.cs('thesis-writers-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
})
it('Credits produced by the faculty', () => {
cy.cs('credits-produced-by-the-faculty-info-box-button').click()
cy.cs('credits-produced-by-the-faculty-info-box-content').contains('Sisältää opintopisteet')
})
})
it('All graphs and tables are shown', () => {
cy.cs('students-of-the-faculty-line-graph-section').should('be.visible')
cy.cs('students-of-the-faculty-interactive-data-table').should('be.visible')
cy.cs('graduated-of-the-faculty-line-graph-section').should('be.visible')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('be.visible')
cy.cs('thesis-writers-of-the-faculty-line-graph-section').should('be.visible')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('be.visible')
cy.cs('credits-produced-by-the-faculty-stacked-bar-chart-section').should('be.visible')
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('be.visible')
})
it('Correct tabs are shown', () => {
cy.cs('faculty-tabs').should('contain', 'Basic information')
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
cy.cs('faculty-tabs').should('contain', 'Graduation times')
cy.cs('faculty-tabs').should('not.contain', 'Update statistics')
})
it('Toggle years works', () => {
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
cy.cs('year-toggle').click()
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
})
it('Toggle programmes works', () => {
cy.cs('faculty-programmes-shown-info').should('not.exist')
cy.cs('programme-toggle').click()
cy.cs('faculty-programmes-shown-info').should('be.visible')
})
it('Toggle study rights works', () => {
cy.cs('faculty-exclude-specials-info').should('not.exist')
cy.cs('study-right-toggle').click()
cy.cs('faculty-exclude-specials-info').should('be.visible')
})
it.skip('Data can be exported to Excel files', { retries: 2 }, () => {
// TODO: Fix this test
const sections = [
'StudentsOfTheFaculty',
'GraduatedOfTheFaculty',
'ThesisWritersOfTheFaculty',
'CreditsProducedByTheFaculty',
]
sections.forEach(section => {
cy.cs(`${section}Graph`).within(() => {
cy.get('[aria-label="View chart menu, Chart"]').click({ force: true })
cy.contains('li.highcharts-menu-item', 'Download XLS').click()
const downloadedFile = `oodikone_${section}_H50_${timestamp}.xls`
cy.readFile(path.join(downloadsFolder, downloadedFile))
})
})
})
})
})
describe('Students by starting year tab', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
cy.contains('Students by starting year').click()
})
it('Export button', { retries: 2 }, () => {
cy.cs('faculty-student-table-export-button').should('be.visible').and('not.be.disabled').click()
const downloadedFile = `oodikone_H50_programme_stats_${timestamp}.xlsx`
cy.readFile(path.join(downloadsFolder, downloadedFile))
})
it('Info box', () => {
cy.cs('faculty-student-table-info-box-button').click()
cy.cs('faculty-student-table-info-box-content').contains('Opiskelijat, joiden')
})
it.skip('Study right toggle', () => {
// TODO: Implement
})
it.skip('Graduated toggle', () => {
// TODO: Implement
})
it.skip('Hoverable country list', () => {
// TODO: Implement
})
it.skip('Expandable rows', () => {
// TODO: Implement
})
it.skip('Population links', () => {
// TODO: Implement
})
it('Percentage toggle works', () => {
cy.cs('faculty-student-stats-table').should('be.visible')
cy.cs('faculty-student-stats-table').should('not.contain', '92.3 %')
cy.cs('percentage-toggle').click()
cy.cs('faculty-student-stats-table').should('contain', '92.3 %')
})
})
describe('Progress tab', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
cy.contains('Progress').click()
})
it.skip('Export button', { retries: 2 }, () => {
// TODO: Fix this test
cy.cs('FacultyProgress-export-button').should('be.visible').and('not.be.disabled').click()
const downloadedFile = `oodikone_H50_progress_tab_${timestamp}.xlsx`
cy.readFile(path.join(downloadsFolder, downloadedFile))
})
it.skip('Info boxes', () => {
cy.cs('FacultyProgress-info-box-button').click()
cy.cs('FacultyProgress-info-box-content').contains('Kuvaa tiedekuntaan')
// TODO: Click open the accordion first
cy.cs('BachelorMastersProgress-info-box-button').click()
cy.cs('BachelorMastersProgress-info-box-content').contains('The starting year is the')
})
it.skip('Progress bar charts exist', () => {
// TODO: Click open the accordion first
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
cy.cs('FacultyBachelorMastersProgressBarChart').should('be.visible')
cy.cs('FacultyMastersProgressBarChart').should('be.visible')
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
})
it.skip('Progress tables exist', () => {
// TODO: Click open the accordion first
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
cy.cs('FacultyBachelorMasterProgressTable').should('be.visible')
cy.cs('FacultyMastersProgressTable').should('be.visible')
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
})
it.skip('Graduated toggle', () => {
// TODO: Implement
})
it.skip('Study right toggle', () => {
// TODO: Implement
})
})
describe('Graduation times tab', () => {
beforeEach(() => {
cy.init('/faculties')
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
cy.contains('Graduation times').click()
})
it.skip('Export button', { retries: 2 }, () => {
// TODO: Fix this test
cy.cs('average-graduation-times-export-button').should('be.visible').and('not.be.disabled').click()
const downloadedFile = `oodikone_H50_graduation_times_${timestamp}.xlsx`
cy.readFile(path.join(downloadsFolder, downloadedFile))
})
it('Info boxes', () => {
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset')
})
it.skip('Study times toggle', () => {
// TODO: Implement
})
it.skip('Year toggle', () => {
// TODO: Implement
})
it.skip('Degree programme toggle', () => {
// TODO: Implement
})
it('Graphs are visible', () => {
cy.cs('bachelor-graduation-times-section').should('be.visible')
// ! No test data for bachelor + master
cy.cs('master-graduation-times-section').should('be.visible')
cy.cs('doctor-graduation-times-section').should('be.visible')
})
})
})
-468
View File
@@ -1,468 +0,0 @@
/// <reference types="cypress" />
const setClockToMockedDate = () => {
const MOCKED_DATE = new Date('2024-08-30')
cy.clock(MOCKED_DATE, ['Date'])
}
const checkFilteredStudentCount = studentCount => {
cy.contains(`Students (${studentCount})`)
}
describe("Population statistics with a master's programme", { testIsolation: false }, () => {
const pathToMathMSc2020 = '/populations?years=2020&programme=MH50_001&semesters=FALL&semesters=SPRING'
const defaultAmountOfStudents = 26
const expectedBaMaStudents = 24
const expectedMasterOnlyStudents = 2
before(() => cy.init(pathToMathMSc2020))
it('Study track filter works', () => {
cy.cs('studyTrackFilter-header').click()
cy.selectFromDropdown('studyTrackFilter', 'Matematiikka ja soveltava matematiikka (MAST-MSM)', true)
checkFilteredStudentCount(15)
cy.cs('studyTrackFilter-clear').click()
cy.cs('studyTrackFilter-header').click()
})
it('Study right type filter works', () => {
cy.cs('studyRightTypeFilter-header').click()
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
// Bachelor + master
cy.get('input[name="studyRightTypeFilter"][value="1"]').click()
cy.get('input[name="studyRightTypeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(expectedBaMaStudents)
// Master only
cy.get('input[name="studyRightTypeFilter"][value="2"]').click()
cy.get('input[name="studyRightTypeFilter"][value="2"]').should('be.checked')
checkFilteredStudentCount(expectedMasterOnlyStudents)
// All
cy.get('input[name="studyRightTypeFilter"][value="0"]').click()
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('studyRightTypeFilter-header').click()
})
})
describe.skip('Population Statistics', { testIsolation: false }, () => {
const pathToMathBSc2020 = '/populations?years=2020&programme=KH50_001&semesters=FALL&semesters=SPRING'
const defaultAmountOfStudents = 27
const graduated = 16
const transferred = 3
before(() => cy.init(pathToMathBSc2020))
beforeEach(() => setClockToMockedDate())
it.skip('Study right type filter is not visible', () => {
cy.contains('Study right type').should('not.exist')
})
// TODO: FilterRadio is not working correcly
it.skip('Transfer filter works and is set to "not transferred" by default', () => {
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').click()
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').should('be.checked')
checkFilteredStudentCount(transferred)
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').click()
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents + transferred)
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').click()
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Graduation filter works', () => {
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(graduated)
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents - graduated)
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').should('be.checked')
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('graduatedFromProgrammeFilter-header').click()
})
it('Enrollment filter works', () => {
cy.cs('enrollmentStatusFilter-header').click()
cy.selectFromDropdown('enrollmentStatusFilter-status', 0)
cy.selectFromDropdown('enrollmentStatusFilter-semester', 'Syksy 2023', true)
checkFilteredStudentCount(25)
cy.cs('enrollmentStatusFilter-clear').click()
cy.cs('enrollmentStatusFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Credit filter works', () => {
cy.cs('creditsEarnedFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('creditsEarnedFilter', 50, 150, 12)
cy.cs('creditsEarnedFilter-clear').click()
cy.cs('creditsEarnedFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Age filter works', () => {
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('ageFilter', 23, 30, 21)
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Gender filter works', () => {
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(13)
cy.selectFromDropdown('genderFilter', 1)
checkFilteredStudentCount(14)
cy.selectFromDropdown('genderFilter', 2)
checkFilteredStudentCount(0)
cy.selectFromDropdown('genderFilter', 3)
checkFilteredStudentCount(0)
cy.cs('genderFilter-clear').click()
cy.cs('genderFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Starting year filter works', () => {
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 0)
checkFilteredStudentCount(1)
cy.selectFromDropdown('startYearAtUniFilter', 7)
checkFilteredStudentCount(21)
cy.cs('startYearAtUniFilter-clear').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Admission type filter works', () => {
cy.cs('admissionTypeFilter-header').click()
cy.selectFromDropdown('admissionTypeFilter', 0)
checkFilteredStudentCount(18)
cy.selectFromDropdown('admissionTypeFilter', 1)
checkFilteredStudentCount(6)
cy.cs('admissionTypeFilter-clear').click()
cy.cs('admissionTypeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Courses filter works', () => {
const courses = [
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
]
cy.cs('courseFilter-header').click()
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
checkFilteredStudentCount(9)
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
checkFilteredStudentCount(7)
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
checkFilteredStudentCount(6)
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 1)
checkFilteredStudentCount(4)
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
checkFilteredStudentCount(7)
cy.cs('courseFilter-clear').click()
cy.cs('courseFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Filter combinations work', () => {
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(graduated)
cy.cs('ageFilter-header').click()
cy.setRangeSelect('ageFilter', 23, 30, 12)
cy.cs('graduatedFromProgrammeFilter-clear').click()
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('"Reset All Filters" button works', () => {
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(13)
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 7)
checkFilteredStudentCount(8)
cy.cs('reset-all-filters').click()
cy.cs('genderFilter-header').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
describe('Population Statistics with Bachelor + Master', { testIsolation: false }, () => {
const pathToMathBSc2017 =
'/populations?years=2017&programme=KH50_001&showBachelorAndMaster=true&semesters=FALL&semesters=SPRING'
const defaultAmountOfStudents = 47
before(() => cy.init(pathToMathBSc2017))
it('Graduation filter works', () => {
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
checkFilteredStudentCount(42)
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').should('be.checked')
checkFilteredStudentCount(18)
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
checkFilteredStudentCount(5)
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').should('be.checked')
checkFilteredStudentCount(29)
cy.cs('graduatedFromProgrammeFilter-clear').click()
cy.cs('graduatedFromProgrammeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Study right status filter works', () => {
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('studyRightStatusFilter-header').click()
// Bachelor
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').should('be.checked')
checkFilteredStudentCount(0)
// Master (combined)
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').should('be.checked')
checkFilteredStudentCount(0)
// Non-Bachelor
cy.get('input[name="studyRightStatusFilter"][value="Passive Bachelor study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Passive Bachelor study right"]').should('be.checked')
checkFilteredStudentCount(5)
// Non-Master (combined)
cy.get('input[name="studyRightStatusFilter"][value="Passive Master study right"]').click()
cy.get('input[name="studyRightStatusFilter"][value="Passive Master study right"]').should('be.checked')
checkFilteredStudentCount(24)
cy.cs('studyRightStatusFilter-clear').click()
cy.cs('studyRightStatusFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
// FIXME: Flaky
describe.skip('Course Statistics', { testIsolation: false }, () => {
const pathToLimits2021 =
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats'
const defaultAmountOfStudents = 48
before(() => cy.init(pathToLimits2021))
beforeEach(() => setClockToMockedDate())
it('Grade filter works', () => {
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('gradeFilter-header').click()
cy.cs('gradeFilter-5').click()
checkFilteredStudentCount(23)
cy.cs('gradeFilter-3').click()
checkFilteredStudentCount(29)
cy.cs('gradeFilter-3').click()
cy.cs('gradeFilter-5').click()
cy.cs('gradeFilter-clear').should('not.exist')
cy.cs('gradeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Programme filter works and defaults to "Attainment"', () => {
checkFilteredStudentCount(defaultAmountOfStudents)
cy.cs('programmeFilter-header').click()
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Attainment')
cy.selectFromDropdown('programmeFilter', 1, true)
checkFilteredStudentCount(33)
cy.cs('programmeFilter-clear').click()
cy.cs('programmeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Age filter works', () => {
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('ageFilter', 23, 30, 36)
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Gender filter works', () => {
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(9)
cy.selectFromDropdown('genderFilter', 1)
checkFilteredStudentCount(39)
cy.selectFromDropdown('genderFilter', 2)
checkFilteredStudentCount(0)
cy.selectFromDropdown('genderFilter', 3)
checkFilteredStudentCount(0)
cy.cs('genderFilter-clear').click()
cy.cs('genderFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Starting year filter works', () => {
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 0)
checkFilteredStudentCount(1)
cy.selectFromDropdown('startYearAtUniFilter', 10)
checkFilteredStudentCount(24)
cy.cs('startYearAtUniFilter-clear').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Filter combinations work', () => {
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
cy.setRangeSelect('ageFilter', 23, 30, 36)
cy.cs('gradeFilter-header').click()
cy.cs('gradeFilter-5').click()
checkFilteredStudentCount(17)
cy.cs('gradeFilter-clear').click()
cy.cs('gradeFilter-header').click()
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
describe('Custom Population Statistics', { testIsolation: false }, () => {
let defaultAmountOfStudents
before(() => {
cy.init('/custompopulation')
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
defaultAmountOfStudents = students.length
cy.cs('student-number-input').click()
cy.cs('student-number-input').type(students.join('{enter}'))
cy.cs('search-button').click()
})
})
beforeEach(() => setClockToMockedDate())
it('Age filter works', () => {
cy.cs('ageFilter-header').click()
cy.setRangeSelect('ageFilter', 24, 28, 5)
cy.cs('ageFilter-clear').click()
cy.cs('ageFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Programme filter works and defaults to "Active Study Right"', () => {
cy.cs('programmeFilter-header').click()
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Active Study Right')
cy.selectFromDropdown('programmeFilter', 1, true)
checkFilteredStudentCount(3)
cy.cs('programmeFilter-clear').click()
cy.cs('programmeFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Gender filter works', () => {
cy.cs('genderFilter-header').click()
cy.selectFromDropdown('genderFilter', 0)
checkFilteredStudentCount(5)
cy.selectFromDropdown('genderFilter', 1)
checkFilteredStudentCount(3)
cy.selectFromDropdown('genderFilter', 2)
checkFilteredStudentCount(0)
cy.selectFromDropdown('genderFilter', 3)
checkFilteredStudentCount(0)
cy.cs('genderFilter-clear').click()
cy.cs('genderFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Starting year filter works', () => {
cy.cs('startYearAtUniFilter-header').click()
cy.selectFromDropdown('startYearAtUniFilter', 0)
checkFilteredStudentCount(2)
cy.cs('startYearAtUniFilter-clear').click()
cy.cs('startYearAtUniFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
it('Courses filter works', () => {
const courses = [
{ code: 'MAT11005', name: 'Integraalilaskenta' },
{ code: 'MAT21007', name: 'Mitta ja integraali' },
]
cy.cs('courseFilter-header').click()
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
checkFilteredStudentCount(7)
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
checkFilteredStudentCount(6)
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
checkFilteredStudentCount(6)
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 3)
checkFilteredStudentCount(6)
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
checkFilteredStudentCount(6)
cy.cs('courseFilter-clear').click()
cy.cs('courseFilter-header').click()
checkFilteredStudentCount(defaultAmountOfStudents)
})
})
-89
View File
@@ -1,89 +0,0 @@
/// <reference types="cypress" />
const chooseSemester = (semester, fromOrTo) => {
cy.cs(`semester-${fromOrTo}`).click()
cy.cs(`select-opt-${semester}`).click()
cy.cs(`select-opt-${semester}`).should('not.have.class', 'visible')
}
const checkNumbers = (numbers, numberOfColumns, mode) => {
const offset = 1 // skip first col with codes / names
cy.cs(`ooditable-${mode}`).find('table').find('tbody').as('tbody')
cy.get('@tbody').find('tr').first().as('totalRow')
cy.get('@totalRow').find('td').should('have.length', numberOfColumns)
cy.get('@totalRow').find('td').eq(0).should('contain', 'All courses total')
numbers.forEach((number, index) => {
cy.get('@totalRow')
.find('td')
.eq(index + offset)
.should('contain', number)
})
}
describe('When language center is opened', () => {
describe('as an admin user', () => {
beforeEach(() => {
cy.init('/languagecenterview', 'admin')
cy.cs('completions-button').click()
})
describe('Faculties tab', () => {
beforeEach(() => {
chooseSemester('Syksy 2017', 'from')
chooseSemester('Kevät 2024', 'to')
})
it('Initial view is correct', () => {
cy.contains('All courses total')
cy.contains('Academic and Professional')
cy.contains('AYKK-RUKIRJ')
})
it('Faculties tab shows numbers', () => {
cy.get('table > tbody > tr:first').within(() => {
cy.get('td').should('have.length', 15)
cy.get('td').eq(0).contains('All courses total')
const numbers = [2076, 36, 9, 34, 759, 15, 40, 6, 51, 1, 24, 1059, 42, 0]
numbers.forEach((number, index) => {
cy.get('td')
.eq(index + 1)
.contains(number)
})
})
})
it('Faculties tab "exceeding" button works', () => {
cy.cs('difference-button').click()
checkNumbers([66, 3, 0, 4, 18, 0, 1, 1, 4, 0, 0, 31, 3, 1], 15, 'faculties')
})
it('Faculties tab semester selector changes numbers', () => {
chooseSemester('Syksy 2020', 'from')
checkNumbers([1184, 28, 6, 28, 499, 4, 10, 2, 36, 0, 9, 535, 27, 0], 15, 'faculties')
})
})
describe('Semester tab', () => {
beforeEach(() => {
cy.contains('By semesters').click()
chooseSemester('Syksy 2017', 'from')
chooseSemester('Kevät 2024', 'to')
})
it('Semester tab shows numbers', () => {
checkNumbers([2076, 69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1], 16, 'semesters')
})
})
})
describe('with a user with no rights', () => {
it('"Access denied" is shown', () => {
cy.init('/languagecenterview', 'norights')
cy.contains('Access denied')
cy.contains("You don't currently have permission to view this page.")
cy.contains('Special populations').click()
cy.contains('Language center view').should('not.exist')
})
})
})
-226
View File
@@ -1,226 +0,0 @@
/// <reference types="cypress" />
const selectCourseStatus = (courseCode, status) => {
cy.cs(`courseFilter-${courseCode}-selector`).click()
cy.get(`[role="listbox"][aria-labelledby=courseFilter-${courseCode}]`).within(() => {
cy.get('li').contains(status).click()
})
}
describe('Numbers should match between', () => {
describe('Programme courses, Course statistics and Course population (no substitutions, all years)', () => {
const test = [
// Code, name, total students, passed, not completed, failed*, enrolled no grade* (not used by Programme courses)
['Course', 'MAT11001', 'Johdatus yliopistomatematiikkaan', 272, 253, 1 + 18, 1, 18],
['Course', 'MAT11003', 'Raja-arvot', 270, 249, 1 + 20, 1, 20],
['Course', 'MAT11004', 'Differentiaalilaskenta', 262, 248, 1 + 13, 1, 13],
['Course', 'MAT21002', 'Sarjat', 256, 250, 0 + 6, 0, 6],
['Course', 'MAT11005', 'Integraalilaskenta', 251, 234, 0 + 17, 0, 17],
['Course', 'MAT11002', 'Lineaarialgebra ja matriisilaskenta I', 247, 242, 1 + 4, 1, 4],
['Course', 'MAT12003', 'Todennäköisyyslaskenta I', 234, 227, 3 + 4, 3, 4],
['Course', 'MAT21001', 'Lineaarialgebra ja matriisilaskenta II', 230, 216, 4 + 10, 4, 10],
['Course', 'MAT21003', 'Vektorianalyysi I', 228, 202, 3 + 23, 3, 23],
['Course', 'MAT20005', 'Akateemiset taidot', 201, 178, 0 + 23, 0, 23],
['Module', 'MAT110', 'Matematiikka, perusopinnot', 195, 195, 0 + 0, 0, 0],
['Course', 'MAT21014', 'Johdatus logiikkaan I', 188, 170, 0 + 18, 0, 18],
]
// Check test data is correct
test.forEach(entry => {
assert.strictEqual(entry.length, 8)
assert.strictEqual(entry.at(6) + entry.at(7), entry.at(5))
})
it('in Programme courses', () => {
// Move to Math bachelor programme's programme courses
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('ProgrammeCoursesTab').click()
// Wait for tab to load
cy.contains('Programme courses by credit type')
// Sort by Total students
cy.get('thead').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('th').eq(3).click()
})
})
cy.get('tbody').within(() => {
test.forEach((course, index) => {
cy.get('tr')
.eq(index)
.within(() => {
// Check a course's fields
course.forEach((field, fieldIndex) => {
// Failed, Enrolled no grade values not used
if (fieldIndex <= 5) {
cy.get('td').eq(fieldIndex).contains(field)
}
})
})
})
})
})
it('in Course statistics', () => {
test.forEach(([_courseOrModule, code, name, total, passed, _notCompleted, failed, enrolledNoGrade]) => {
// Move to Course statistics
cy.init(`/coursestatistics?courseCodes=%5B%22${code}%22%5D&combineSubstitutions=false`, 'basic')
cy.url().should('include', `/coursestatistics?courseCodes=%5B%22${code}%22%5D`)
cy.contains('Course statistics')
cy.contains(name) // course name
cy.cs('FromYearSelector').click()
cy.cs('FromYearSelectorOption2017-2018').click()
cy.get('tbody').within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('td').eq(1).contains(total)
cy.get('td').eq(2).contains(passed)
cy.get('td').eq(3).contains(failed) // jump over combined Not completed
cy.get('td').eq(4).contains(enrolledNoGrade)
})
})
})
})
it('in Course population', () => {
test.forEach(([_courseOrModule, code, name, total, passed, _notCompleted, failed, enrolledNoGrade]) => {
// Academic years between 2017-2023
cy.init(
// year - 1949 yields the correct year code
`/coursepopulation?from=${2017 - 1949}&to=${2023 - 1949}&coursecodes=%5B%22${code}%22%5D&includeSubstitutions=false`,
'basic'
)
cy.contains(name) // Course name
// Select course from Course-filter
cy.cs('courseFilter-filter-card').click()
cy.cs('courseFilter-selector').click()
cy.cs('courseFilter-popper').within(() => {
cy.contains('li', `${code} - ${name}`).click() // "code - name"
})
selectCourseStatus(code, 'All')
cy.contains(`Students (${total})`)
selectCourseStatus(code, 'Passed')
cy.contains(`Students (${passed})`)
selectCourseStatus(code, 'Failed')
cy.contains(`Students (${failed})`)
selectCourseStatus(code, 'Enrolled, No Grade')
cy.contains(`Students (${enrolledNoGrade})`)
})
})
})
describe('Programme courses, Course statistics and Course population (no substitutions, single years)', () => {
// MAT21003, Vektorianalyysi I
const test = [
// Year, Total students, Passed, Failed, Enrolled no grade, Not completed (failed + enrolled n.g.)
[2017, 1, 0, 1, 0, 1 + 0],
[2018, 31, 27, 4, 0, 4 + 0],
[2019, 58, 56, 2, 0, 2 + 0],
[2020, 63, 62, 1, 0, 1 + 0],
[2021, 41, 35, 0, 6, 0 + 6],
[2022, 28, 21, 0, 7, 0 + 7],
[2023, 21, 1, 0, 20, 0 + 20],
]
// Check test data is correct
test.forEach(entry => {
assert.strictEqual(entry.length, 6)
assert.strictEqual(entry.at(3) + entry.at(4), entry.at(5))
})
// TODO: Re-enable when migrated to playwright
it.skip('in Programme courses', () => {
cy.init('/study-programme')
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
cy.cs('ProgrammeCoursesTab').click()
test.forEach(([year, total, passed, _failed, _enrolledNoGrade, notCompleted]) => {
cy.cs('to-year-select').click()
cy.cs(`to-year-select-option-${year}`).click()
cy.cs('from-year-select').click()
cy.cs(`from-year-select-option-${year}`).click()
cy.get('tbody')
.contains('tr', 'MAT21003')
.scrollIntoView()
.within(() => {
cy.get('td').eq(3).contains(total)
cy.get('td').eq(4).contains(passed)
cy.get('td').eq(5).contains(notCompleted)
})
})
})
it('in Course statistics', () => {
test.forEach(([year, total, passed, failed, enrolledNoGrade, _notCompleted]) => {
cy.init(`/coursestatistics?courseCodes=%5B%22MAT21003%22%5D&combineSubstitutions=false`, 'basic')
const yearString = `${year}-${year + 1}`
cy.cs('ToYearSelector').click()
cy.cs(`ToYearSelectorOption${yearString}`).click()
cy.cs('FromYearSelector').click()
cy.cs(`FromYearSelectorOption${yearString}`).click()
cy.contains('Student statistics')
// Check that only total and the selected year are visible
cy.get('tbody tr').should('have.length', 2)
// Take the total entry in the table to have enrolled, no grade always be a number
cy.get('tbody')
.contains('tr', 'Total')
.within(() => {
cy.get('td').eq(1).contains(total)
cy.get('td').eq(2).contains(passed)
cy.get('td').eq(3).contains(failed)
cy.get('td').eq(4).contains(enrolledNoGrade)
})
})
})
it('in Course population', () => {
test.forEach(([year, total, passed, failed, enrolledNoGrade, _notCompleted]) => {
cy.init(
// year - 1949 yields the correct year code
`/coursepopulation?from=${year - 1949}&to=${year - 1949}&coursecodes=%5B%22MAT21003%22%5D&includeSubstitutions=false`,
'basic'
)
cy.contains('Vektorianalyysi I') // Course name
// Select course from Course-filter
cy.cs('courseFilter-filter-card').click()
cy.cs('courseFilter-selector').click()
cy.cs('courseFilter-popper').within(() => {
cy.contains('li', 'MAT21003 - Vektorianalyysi I').click() // "code - name"
})
selectCourseStatus('MAT21003', 'All')
cy.contains(`Students (${total})`)
selectCourseStatus('MAT21003', 'Passed')
cy.contains(`Students (${passed})`)
selectCourseStatus('MAT21003', 'Failed')
cy.contains(`Students (${failed})`)
selectCourseStatus('MAT21003', 'Enrolled, No Grade')
cy.contains(`Students (${enrolledNoGrade})`)
})
})
})
})
-443
View File
@@ -1,443 +0,0 @@
/// <reference types="cypress" />
// Now "Class statistics" in UI
const selectStudyProgramme = programme => {
cy.cs('population-programme-selector').within(() =>
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
)
cy.cs('population-programme-selector-parent').click()
cy.cs('population-programme-selector-parent').within(() => cy.contains(programme).click())
}
const selectStudyTrack = studyTrack => {
cy.cs('population-studytrack-selector').within(() =>
cy.get('input').should('have.attr', 'placeholder', 'Select study track')
)
cy.cs('population-studytrack-selector-parent').click()
cy.cs('population-studytrack-selector-parent').within(() => cy.contains(studyTrack).click())
}
describe('Population statistics tests', () => {
const getPath = programme => {
return `/populations?months=49&semesters=FALL&semesters=SPRING&programme=${programme}&years=2020`
}
const pathToMathBSc2020 = getPath('KH50_001')
const pathToMathMSc2020 = getPath('MH50_001')
describe('When using basic user', () => {
beforeEach(() => {
cy.init('/populations')
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.intercept('/api/studyprogrammes/**/studytracks').as('studytracks')
})
describe('Population search', () => {
it('Info box works', () => {
cy.cs('PopulationSearch-info-box-content').should('not.exist')
cy.cs('PopulationSearch-info-box-button').hover()
cy.cs('PopulationSearch-info-box-content').should('be.visible')
cy.cs('PopulationSearch-info-box-content').contains('Tässä osiossa voi tarkastella')
cy.cs('PopulationSearch-info-box-button').trigger('mouseout')
cy.cs('PopulationSearch-info-box-content').should('not.exist')
})
// TODO: Delete MUI
it.skip('Form is usable', { retries: { runMode: 2 } }, () => {
cy.contains('Search for class')
cy.contains('See class').should('be.disabled')
cy.cs('population-year-selector').as('yearSelect')
cy.cs('population-year-decrement').as('yearDecrement')
cy.cs('population-year-increment').as('yearIncrement')
cy.get('@yearSelect').click()
cy.contains('2018 - 2019').click()
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
cy.get('@yearDecrement').click()
cy.get('@yearSelect').within(() => cy.contains('2017 - 2018'))
cy.get('@yearIncrement').click()
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
cy.cs('population-studytrack-selector').within(() => {
cy.get('input').should('have.attr', 'placeholder', 'No study tracks available')
})
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
cy.contains('See class').should('be.enabled')
selectStudyTrack('Matematiikka ja soveltava matematiikka')
cy.contains('See class').should('be.enabled')
})
describe('Correct population is shown for programme', () => {
// TODO: Delete MUI
it.skip('without study tracks', () => {
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
selectStudyProgramme('Matemaattisten tieteiden kandiohjelma')
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('See class').click()
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2017 - 2018, 47 students')
})
// TODO: Delete MUI
it.skip('with study tracks', () => {
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
selectStudyTrack('Matematiikka ja soveltava matematiikka')
cy.contains('See class').click()
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma 2017 - 2018')
cy.contains('studytrack MAST-MSM')
cy.contains('Class size 1 students')
})
})
})
it('Population statistics is usable on general level', () => {
cy.visit(pathToMathBSc2020)
cy.cs('filtered-students')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2020 - 2021, 30 students')
cy.contains('Excludes exchange students')
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
cy.cs('filtered-students')
})
// This test sometimes fails on headless mode. It seems that the click on the
// 'Fetch class with new settings' button doesn't always trigger history.push()
// so the page doesn't reload. This is why waiting also doesn't help.
it('Advanced settings work', { retries: 2 }, () => {
cy.visit(pathToMathMSc2020)
cy.get('[data-cy=advanced-toggle]').click()
// only spring
cy.cs('toggle-fall').click()
cy.contains('Fetch class').click()
cy.contains('Credit accumulation (for 17 students)')
cy.url().should('not.include', 'semesters=FALL')
// only fall
cy.get('[data-cy=advanced-toggle]').click()
cy.cs('toggle-fall').click()
cy.cs('toggle-spring').click()
cy.contains('Fetch class').click()
cy.contains('Credit accumulation (for 9 students)')
cy.url().should('not.include', 'semesters=SPRING')
// spring + fall
cy.get('[data-cy=advanced-toggle]').click()
cy.cs('toggle-spring').click()
cy.contains('Fetch class').click()
cy.contains('Credit accumulation (for 26 students)')
cy.url().should('include', 'semesters=FALL&semesters=SPRING')
})
describe('Credit statistics', () => {
it("'Credits gained' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
cy.visit(pathToMathBSc2020)
const totalStudents = 27
cy.contains('Credit statistics')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.contains('Credits gained').click()
const limits = [1, 45, 90, 135, 180, null]
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
const getTableData = (selector, numbersOfStudents) => {
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
return ranges.map((range, index) => ({
selector: `[data-cy='credits-gained-table-${selector}']`,
start: range[0],
end: range[1],
students: numbersOfStudents[index],
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
}))
}
cy.cs('credits-gained-table-All students of the class').within(() => {
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024')
cy.get('th').eq(1).contains('(49 months)')
cy.get('th').eq(2).contains('Number of students')
cy.get('th').eq(2).contains(`(n = ${totalStudents})`)
cy.get('th').eq(3).contains('Percentage of population')
})
for (const category of [
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
]) {
category.forEach(({ selector, start, end, students, percentage }, index) => {
let value
if (start === null) {
value = '0'
} else if (end === null) {
value = `${start} ≤ credits`
} else {
value = `${start} ≤ credits < ${end}`
}
cy.get(`${selector} [data-cy='credits-gained-table-body']`).within(() => {
cy.get('tr')
.eq(index)
.within(() => {
cy.get('td').eq(1).contains(value)
cy.get('td').eq(2).contains(students)
cy.get('td').eq(3).contains(percentage)
})
})
})
}
cy.get(
"[data-cy='credits-gained-table-All students of the class'] [data-cy='credits-gained-table-body'] td:nth-child(3)"
).then(tds => {
const sum = [...tds].reduce((acc, td) => acc + parseInt(td.innerText, 10), 0)
expect(sum).to.equal(totalStudents)
})
})
it("'Statistics' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
cy.visit(pathToMathBSc2020)
cy.contains('Credit statistics')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
})
cy.cs('credit-statistics-tab').click()
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
const categories = [
{
selector: 'All students of the population',
data: ['6136.50', '227.28', '197.00', '117.94', '60', '533'],
size: 27,
},
{
selector: 'Muu',
data: ['580.00', '193.33', '192.00', '36.75', '149', '239'],
size: 3,
},
{
selector: 'Todistusvalinta',
data: ['3657.50', '203.19', '193.50', '98.35', '60', '460'],
size: 18,
},
{
selector: 'Avoin väylä',
data: ['1899.00', '316.50', '257.00', '150.43', '150', '533'],
size: 6,
},
]
for (const { selector, data, size } of categories) {
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
cy.contains('h5', selector)
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
cy.get('table tbody').within(() => {
rows.forEach((text, index) => {
cy.get('tr')
.eq(index)
.within(() => {
cy.get('td').eq(0).contains(text)
cy.get('td').eq(1).contains(data[index])
})
})
})
})
}
})
})
describe('Courses of class', () => {
it('Is displayed and link to individual course stats page works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.intercept('/api/courseyearlystats**').as('coursePage')
cy.cs('toggle-group-module-MAT110').click()
cy.contains('td', 'MAT11001').siblings().find('[data-testid="NorthEastIcon"]').click()
cy.wait('@coursePage')
cy.url().should('include', '/coursestatistics')
cy.contains('MAT11001') // Johdatus yliopistomatematiikkaan
cy.contains('AYMAT11001') // Avoin yo: Johdatus yliopistomatematiikkaan
cy.contains('57033') // Johdatus yliopistomatematiikkaan
cy.contains('A57033') // Avoin yo: Johdatus yliopistomatematiikkaan
})
it('Curriculum selection works', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.get('[data-cy=curriculum-picker]').contains('2020–2023')
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
cy.get('[data-cy=curriculum-picker]').click()
cy.contains('2023–2026').click()
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
})
it('Courses data is changed when curriculum is changed', () => {
cy.visit(pathToMathBSc2020)
cy.contains('Courses of class').click()
cy.cs('curriculum-picker').scrollIntoView()
cy.cs('curriculum-picker').should('be.visible')
cy.cs('curriculum-picker').click()
cy.contains('2020–2023').click({ force: true })
cy.contains('Students (27)')
cy.cs('toggle-group-module-DIGI-k').should('exist')
cy.cs('toggle-group-module-DIGI-k').scrollIntoView()
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
cy.cs('toggle-group-module-DIGI-k').click({ force: true })
cy.contains('DIGI-100').should('exist')
cy.cs('curriculum-picker').scrollIntoView()
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
cy.cs('curriculum-picker').click()
cy.contains('2023–2026').click({ force: true })
cy.cs('toggle-group-module-DIGI-k').should('exist')
cy.contains('DIGI-100').should('not.exist')
})
it('Courses data is changed when filtered students change', () => {
cy.visit(pathToMathBSc2020)
cy.cs('graduatedFromProgrammeFilter-filter-card').within(() => {
cy.cs('graduatedFromProgrammeFilter-header').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
})
cy.contains('Students (16)')
})
})
describe('Students', () => {
beforeEach(() => {
cy.visit(pathToMathBSc2020)
cy.contains('Students (27)')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
})
})
it("'General tab' is usable", () => {
cy.cs('ooditable-general').within(() => {
cy.contains('522142')
cy.contains('Tilastotiede')
cy.contains('Matematiikka')
cy.contains('Taloustieteen maisteriohjelma')
cy.contains('Todistusvalinta')
cy.contains('Saksa')
cy.contains('Female')
})
})
it("'Courses tab' is usable", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Courses').click()
})
cy.cs('ooditable-courses').within(() => {
cy.contains('MAT12001')
cy.contains('MAT21001')
})
})
it("'Modules tab' Displays correct modules based on the selected programme", () => {
cy.cs('student-table-tabs').contains('Modules').click()
cy.cs('ooditable-modules').within(() => {
cy.contains('MAT011')
cy.contains('MAT110')
})
cy.contains('Courses of class').click()
cy.cs('curriculum-picker').click()
cy.contains('2023–2026').click()
cy.contains('Courses of class').click()
cy.cs('ooditable-modules').within(() => {
cy.contains('MAT011').should('not.exist')
cy.contains('MAT110')
})
})
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
cy.cs('student-table-tabs').within(() => {
cy.contains('Tags').click()
})
cy.contains('No tags defined. You can define them here.').find('a').click()
cy.url().should('include', '/study-programme/KH50_001?tab=4')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Create new tag')
cy.cs('create-button').should('be.disabled')
})
})
})
describe('When using admin', () => {
it('Student list checking works as intended', () => {
cy.init(pathToMathBSc2020, 'admin')
const existing = '433237'
const nonExisting = '550004'
cy.contains('Students (27)')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
})
cy.contains(existing)
cy.contains(nonExisting).should('not.exist')
cy.contains('button', 'Check student numbers').click()
cy.contains('Check for student numbers')
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
cy.contains('button', 'Check students').click()
cy.get('#checkstudentsresults').within(() => {
cy.cs('found-title').click()
cy.cs('found-data').contains(existing)
cy.cs('not-found-title').click()
cy.cs('not-found-data').contains(nonExisting)
cy.cs('not-searched-title').click()
cy.cs('not-searched-data').contains('457144')
})
cy.contains('button', 'Close').click()
cy.contains('Student numbers in list and in Sisu').should('not.exist')
})
})
describe('When using IAM user', () => {
beforeEach(() => {
cy.init(pathToMathBSc2020, 'onlyiamrights')
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2020 - 2021, 30 students')
})
it('Population statistics is visible', () => {
cy.cs('PopulationQueryCard').within(() => {
cy.contains('Excludes exchange students')
cy.contains('Excludes students with non-degree study right')
cy.contains('Excludes students who have transferred out of this programme')
})
})
it('Only correct panels are visible', () => {
cy.cs('panelview-parent').within(() => {
cy.cs('Credit accumulation (for 27 students)')
cy.cs('Credit statistics')
cy.cs('Age distribution')
cy.cs('Courses of class')
})
})
it('Ages cannot be ungrouped', () => {
cy.contains('Age distribution')
.parent()
.then($parentDiv => {
if (!$parentDiv.hasClass('active')) cy.contains('Age distribution').click()
})
cy.contains('ui.checked.toggle.checkbox', 'Group ages').should('not.exist')
})
it('Age filter is not visible', () => {
cy.get("[data-cy='filtered-students']")
cy.get("[data-cy='Age-filter-card']").should('not.exist')
})
it('Students tab is not available', () => {
cy.contains('Students (27)').should('not.exist')
})
})
})
-85
View File
@@ -1,85 +0,0 @@
import { userHeaders } from '../support/commands.js'
const getBaseRoutes = [
['/', {}],
['/changelog', {}],
['/completedcoursessearch', {}],
['/university', {}],
['/feedback', {}],
['/populations', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/study-programme', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/study-programme/KH50_001', { roles: ['fullSisuAccess'], programmeRights: ['KH50_001'] }],
['/coursepopulation', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/users', { roles: ['admin'] }],
['/users/334', { roles: ['admin'] }],
['/updater', { roles: ['admin'] }],
['/coursestatistics', { roles: ['fullSisuAccess', 'courseStatistics'], programmeRights: [] }],
['/faculties', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
['/faculties/hy-org-1000000911', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
['/students', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/students/545400', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/custompopulation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/teachers', { roles: ['teachers'] }],
['/teachers/hy-hlo-1463447', { roles: ['teachers'] }],
['/studyguidancegroups', { roles: ['studyGuidanceGroups'] }],
['/studyguidancegroups/otm-b3b52ff5-88f6-4d52-bd18-0588023b6322', { userId: '', roles: ['studyGuidanceGroups'] }],
['/close-to-graduation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'] }],
['/languagecenterview', { iamGroups: ['grp-kielikeskus-esihenkilot'] }],
]
for (const user of userHeaders) {
if (user.uid === 'admin') continue
describe(`${user.displayname} has access only to views that were intended`, () => {
let userInfo
before(() => {
cy.request({ method: 'GET', url: '/api/login', headers: user }).then(({ body }) => (userInfo = body?.user))
})
beforeEach(() => cy.wrap(userInfo).should('not.be.undefined'))
beforeEach(() => {
cy.intercept('**/api/**', req => {
req.on('response', res => {
expect(res.statusCode, `${req.method} ${req.url}`).to.be.lessThan(500)
})
})
})
for (const [route, requirements] of getBaseRoutes) {
it(`Checking access ${route}`, () => {
cy.init(route, user.uid)
const requiredUserId = !requirements.userId || requirements.userId === userInfo.userId
const requiredRoles = !requirements.roles || requirements.roles?.some(role => userInfo.roles?.includes(role))
const requiredRights =
!requirements.requireProgrammeRights ||
userInfo.roles?.includes('fullSisuAccess') ||
requirements.programmeRights.every(role => userInfo.programmeRights?.includes(role))
// This is defined in frontend ProtectedRoute component.
const specialNeedsRoutes = [
'populations',
'study-programme',
'coursepopulation',
'coursestatistics',
'students',
'custompopulation',
]
const mankeli = specialNeedsRoutes.some(path => route.includes(path))
? requiredRoles || requiredRights
: requiredRoles && requiredRights
const shouldHaveAccess = mankeli && requiredUserId
if (shouldHaveAccess) {
cy.contains('Something broke').should('not.exist')
cy.contains('Access denied').should('not.exist')
} else {
cy.contains('Access denied')
}
})
}
})
}
-294
View File
@@ -1,294 +0,0 @@
/// <reference types="cypress" />
const student = {
firstNames: 'Varpu Roope',
lastName: 'Mårtensson',
studentNumber: '550003',
sisPersonId: 'hy-hlo-115926826',
email: 'sisutestidata134902@testisisudata.fi',
}
const typeToSearch = text => {
cy.cs('student-search').type(text)
}
const typeStudentNumberAndClick = studentNumber => {
typeToSearch(studentNumber)
cy.contains('td', studentNumber).click()
}
describe('Students tests', () => {
describe('When using basic user', () => {
beforeEach(() => {
cy.init('')
cy.contains('Students').click()
cy.url().should('include', '/students')
cy.contains('Show student names')
})
// FIXME: Flaky
it('Students search form is usable', () => {
typeToSearch(student.lastName)
cy.contains('Student number')
cy.contains('Last name').should('not.exist')
cy.contains('First names').should('not.exist')
cy.contains('Started')
cy.contains('Credits')
cy.contains('Active study rights')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.lastName).should('not.exist')
cy.cs('toggleStudentNames').click()
cy.contains(student.firstNames)
cy.contains('Student number')
cy.contains('Last name')
cy.contains('First names')
cy.contains('Started')
cy.contains('Credits')
cy.contains('Active study rights')
cy.contains(student.firstNames)
cy.contains(student.lastName)
cy.cs('toggleStudentNames').click()
cy.contains('Student number')
cy.contains('Last name').should('not.exist')
cy.contains('First names').should('not.exist')
cy.contains('Started')
cy.contains('Credits')
cy.contains('Active study rights')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.lastName).should('not.exist')
})
it('Search term must be at least 4 characters long', () => {
typeToSearch(student.lastName.slice(0, 3))
cy.contains('Search term is not accurate enough')
typeToSearch(student.lastName.slice(3))
cy.get('table tbody tr').should('have.length', 1)
})
it('Can search with student number too', () => {
typeToSearch(student.studentNumber)
cy.contains(student.studentNumber)
})
it('Can get student specific page by clicking student', () => {
typeStudentNumberAndClick(student.studentNumber)
cy.contains('Matemaattisten tieteiden kandiohjelma (01.08.2020–31.07.2027)')
cy.contains(student.lastName).should('not.exist')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.email).should('not.exist')
cy.cs('toggleStudentNames').click()
cy.contains(student.lastName)
cy.contains(student.firstNames)
cy.contains(student.email)
cy.cs('toggleStudentNames').click()
cy.contains(student.lastName).should('not.exist')
cy.contains(student.firstNames).should('not.exist')
cy.contains(student.email).should('not.exist')
})
it("'Update student' button is not shown", () => {
typeStudentNumberAndClick(student.studentNumber)
cy.cs('student-info-card').within(() => {
cy.contains('button', 'Update student').should('not.exist')
})
})
it('Can get back to search menu', () => {
typeStudentNumberAndClick(student.studentNumber)
cy.go('back')
cy.contains('Student number').should('not.exist')
cy.contains('Credits').should('not.exist')
})
it('Can jump to course', () => {
typeStudentNumberAndClick(student.studentNumber)
cy.contains('Tilastollinen päättely I (MAT12004)')
.parent()
.siblings()
.last()
.within(() => {
cy.get('a').click()
})
cy.url().should('include', '/coursestatistics')
cy.contains('MAT12004') // Tilastollinen päättely I
cy.contains('AYMAT12004') // Avoin yo: Tilastollinen päättely I
cy.contains('57046') // Johdatus tilastolliseen päättelyyn
})
it('Has correct Sisu link', () => {
typeStudentNumberAndClick(student.studentNumber)
cy.cs('sisu-link')
.should('have.attr', 'href')
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sisPersonId}/basic/basic-info`)
})
it('Semester enrollments can be toggled', () => {
typeStudentNumberAndClick(student.studentNumber)
const programmes = [
'Oikeustieteen tohtoriohjelma',
'Matemaattisten tieteiden kandiohjelma',
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
]
cy.cs('student-info-card').within(() => {
cy.contains('Enrollments').click()
cy.get('table tbody tr')
.should('have.length', 3)
.each(($tr, index) => {
cy.wrap($tr).within(() => {
cy.get('td').eq(0).should('have.text', programmes[index])
})
})
})
})
it("Searching with bad name doesn't yield results", () => {
typeToSearch('SWAG LITTINEN')
cy.contains('Student number').should('not.exist')
})
it("Searching with bad student number doesn't yield results", () => {
typeToSearch('01114')
cy.contains('Student number').should('not.exist')
})
it('Can jump to population page', () => {
typeStudentNumberAndClick(student.studentNumber)
cy.cs('study-rights-section').within(() => {
cy.contains('Matemaattisten tieteiden kandiohjelma')
.parent()
.within(() => cy.get('a').click())
})
cy.contains('Matemaattisten tieteiden kandiohjelma')
cy.contains('Class of 2020 - 2021, 30 students')
})
it('Grade graph works in all three different modes', () => {
typeStudentNumberAndClick(student.studentNumber)
cy.contains('button', 'Grade graph').click()
cy.contains('button', 'Show group mean').click()
cy.cs('group-size-input').within(() => {
cy.contains('label', 'Group size')
cy.get('input').should('have.value', '5')
})
cy.cs('group-size-input').within(() => {
cy.contains('label', 'Group size')
cy.get('input').clear()
cy.get('input').type('10')
cy.get('input').should('have.value', '10')
})
cy.contains('button', 'Show semester mean').click()
})
describe('Bachelor Honours section', () => {
it("Shows 'Qualified for Honours' tag and main modules info when the student is qualified", () => {
cy.visit('/students/495976')
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-qualified]', 'Qualified for Honours')
cy.contains('Study modules').click()
cy.get('[data-cy=main-modules] tbody tr')
.should('have.length', 3)
.each(($tr, index) => {
const info = [
['07.05.2020', 'Matemaattisten tieteiden kandiohjelma (KH50_001)', 'Hyv.'],
['08.05.2018', 'Matematiikka, perusopinnot (MAT110)', '4'],
['25.02.2020', 'Matematiikka, aineopinnot (MAT210)', '4'],
]
cy.wrap($tr).within(() => {
info[index].forEach((text, i) => {
cy.get('td').eq(i).contains(text)
})
})
})
})
it("Shows 'Did not graduate in time' when the student has graduated but not in time", () => {
cy.visit('/students/540355')
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
cy.contains('[data-cy=honours-chip-error]', 'Did not graduate in time')
})
it("Shows 'Module grades too low' when the student has graduated in time but has too low grades", () => {
cy.visit('/students/547934')
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
cy.contains('[data-cy=honours-chip-error]', 'Module grades too low')
})
it("Shows 'Might need further inspection' when the student has graduated in time but has more than four main modules", () => {
cy.visit('/students/478837')
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-inspection]', 'Might need further inspection')
})
it("Shows 'Has not graduated' when the student has not graduated", () => {
cy.visit(`students/${student.studentNumber}`)
cy.contains('h2', 'Bachelor Honours')
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
cy.contains('[data-cy=honours-chip-error]', 'Has not graduated')
})
})
})
// Use admin to see all students
describe('When using admin user', () => {
beforeEach(() => {
cy.init('/students', 'admin')
})
it('Does not crash if student has no study rights or courses', () => {
typeStudentNumberAndClick('450730')
cy.contains('Credits: 0')
})
it("'Update student' button is shown", () => {
typeStudentNumberAndClick(student.studentNumber)
cy.cs('student-info-card').within(() => {
cy.contains('button', 'Update student')
})
})
it('Bachelor Honours section is not shown for students outside of Faculty of Science', () => {
cy.visit('/students/453146')
cy.contains('h2', 'Bachelor Honours').should('not.exist')
})
it('When a study plan is selected, courses included in the study plan are highlighted with a blue background', () => {
cy.visit('/students/550789')
cy.contains('Study rights')
cy.get('[data-cy=KH40_005-0]').contains('Kulttuurien tutkimuksen kandiohjelma')
cy.get('[data-cy=KH40_005-0]').within(() => {
cy.get('td').eq(1).click()
})
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)').should(
'have.css',
'background-color',
'rgb(232, 244, 255)'
)
})
it("If there's a study plan corresponding to the degree programme, completed credits are displayed", () => {
cy.visit('/students/550789')
cy.contains('Study rights')
cy.get('[data-cy=KH40_005-0]').contains('Kulttuurien tutkimuksen kandiohjelma')
cy.get('[data-cy=KH40_005-0]').within(() => {
cy.get('td')
.eq(4)
.contains(/^185 cr$/)
})
})
it("If there's a study plan corresponding to the degree programme and the student hasn't graduated, percentage of completion is also displayed", () => {
cy.visit('/students/458723')
cy.contains('Study rights')
cy.get('[data-cy=KH20_001-0]').contains('Oikeusnotaarin koulutusohjelma')
cy.get('[data-cy=KH20_001-0]').within(() => {
cy.get('td').eq(4).contains('69% (125 cr)')
})
})
})
})
-164
View File
@@ -1,164 +0,0 @@
/// <reference types="cypress" />
describe('Study guidance group tests', () => {
describe('Study guidance group overview page', () => {
describe('without study guidance groups', () => {
it('shows the correct notification', () => {
cy.init('/studyguidancegroups', 'admin')
cy.contains('Study guidance groups')
cy.contains('You do not have access to any study guidance groups.')
})
})
describe('without correct rights', () => {
it('shows the correct notification', () => {
cy.init('/studyguidancegroups')
cy.contains('Access denied')
cy.contains(
"You don't currently have permission to view this page. If you believe this is a mistake, please contact oodikone@helsinki.fi."
)
})
})
describe('with study guidance groups', () => {
beforeEach(() => {
cy.init('/studyguidancegroups', 'onlystudyguidancegroups')
cy.intercept('/api/studyguidancegroups', { fixture: 'studyGuidanceGroups.json' })
})
it('shows the correct data', () => {
cy.contains('Study guidance groups')
const studyGuidanceGroupsTableContent = [
['MAT kandit 2020', 3, 'Matemaattisten tieteiden kandiohjelma', '2020 - 2021'],
['Oma ohjausryhmä', 2, 'Add degree programme', 'Add starting year'],
]
cy.checkTableStats(studyGuidanceGroupsTableContent, 'study-guidance-group-overview')
})
it('has working links to single study guidance groups', () => {
cy.cs('study-guidance-group-link-sgg-cypress-1').click()
cy.url().should('eq', `${Cypress.config().baseUrl}/studyguidancegroups/sgg-cypress-1`)
})
})
})
describe('Page for single study guidance group', () => {
beforeEach(() => {
cy.intercept('/api/studyguidancegroups', { fixture: 'studyGuidanceGroups.json' })
})
it('has a working back button', () => {
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
cy.get('button').contains('Back to groups').click()
cy.url().should('eq', `${Cypress.config().baseUrl}/studyguidancegroups`)
})
describe('without associated degree programme and year', () => {
beforeEach(() => {
cy.init('/studyguidancegroups/sgg-cypress-2', 'onlystudyguidancegroups')
})
it('shows the correct panes', () => {
cy.contains('Oma ohjausryhmä')
cy.cs('Credit accumulation (for 2 students)')
cy.cs('Age distribution')
cy.cs('Courses of population')
cy.contains('Students (2)')
cy.cs('Credit statistics').should('not.exist')
})
it.skip('students table has the correct tabs', () => {
cy.contains('Students (2)').click()
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
expect(tabNames).to.deep.equal(['General'])
})
})
it.skip('general tab of the students table has the correct columns', () => {
const colHeaders = [
'Student number',
'All credits',
'Credits since 1.1.1970',
'Start year at uni',
'Degree programmes',
'Tags',
]
cy.contains('Students (2)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
colHeaders.every(heading => cy.contains(heading))
})
})
})
describe('with associated degree programme and year', () => {
beforeEach(() => {
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
})
it('shows the correct labels', () => {
cy.get('h4').contains('MAT kandit 2020')
cy.get('.MuiChip-root:first').contains('Matemaattisten tieteiden kandiohjelma')
cy.get('.MuiChip-root:last').contains('2020 - 2021')
})
it('shows the correct panes', () => {
cy.cs('Credit accumulation (for 3 students)')
cy.cs('Credit statistics')
cy.cs('Age distribution')
cy.cs('Courses of population')
cy.contains('Students (3)')
})
it("clicking the 'Show credits starting from the associated academic year' filter", () => {
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
cy.cs('Credit accumulation (for 3 students)').click()
cy.get('label').contains('Show credits starting from the associated academic year').click()
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="true"]')
cy.get('.date-picker')
.eq(0)
.within(() => cy.get('input').should('have.value', '01.08.2020'))
cy.contains('Reset All Filters')
cy.get('label').contains('Show credits starting from the associated academic year').click()
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
})
it.skip('students table has the correct tabs', () => {
cy.contains('Students (3)').click()
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
expect(tabNames).to.deep.equal(['General', 'Courses', 'Modules', 'Progress'])
})
})
it.skip('general tab of the students table has the correct columns', () => {
const colHeaders = [
'Student number',
'All credits',
'Credits in HOPS',
'Credits since 1.8.2020',
'Start of study right',
'Started in programme',
'Semesters present',
'Graduation date',
'Start year at uni',
'Other programmes',
'Transferred from',
'Admission type',
'Gender',
'Citizenships',
'Curriculum period',
'Latest attainment date',
'Tags',
]
cy.contains('Students (3)').click()
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
colHeaders.every(heading => cy.contains(heading))
})
})
})
})
})
-64
View File
@@ -1,64 +0,0 @@
/// <reference types="cypress" />
describe('Teachers page tests', () => {
beforeEach(() => {
// login as admin = has teacher rights
cy.init('/teachers', 'admin')
cy.url().should('include', '/teachers')
})
const teacher1 = 'Luokkanen Liisa Viljami'
const teacher2 = 'Perälä Juhani Susanna'
const statisticsHeaders = ['Name', 'Credits', 'Credits transferred', 'Passed']
it('Check Statistics', () => {
cy.cs('semester-start').click()
cy.contains('Syksy 2020').click()
cy.cs('course-providers').click()
cy.contains('Matemaattisten tieteiden kandiohjelma').click()
cy.get('body').click(0, 0) // Click outside of the select
cy.cs('search-statistics').click()
cy.contains('Teacher')
cy.get('table thead tr th').should('have.length', 4)
statisticsHeaders.forEach((header, index) => {
cy.get('table thead tr th').eq(index).should('contain', header)
})
cy.contains('td', teacher1).siblings().eq(0).contains('235')
cy.contains('td', teacher1).siblings().eq(2).contains('97.40%')
cy.contains('td', teacher2).siblings().eq(0).contains('395')
cy.contains('td', teacher2).siblings().eq(2).contains('98.78%')
})
it('Teacher search works', () => {
cy.cs('Search').click()
cy.cs('teacher-search').type(teacher1.split(' ')[0])
cy.get('table tbody tr').should('have.length', 4)
cy.get('table tbody tr').eq(2).contains('td', teacher1)
})
it('Can check teacher page', () => {
cy.cs('Search').click()
cy.cs('teacher-search').type(teacher2)
// Prevent opening in new tab
cy.contains('a', teacher2).invoke('removeAttr', 'target').click()
cy.url().should('include', '/teachers/hy-hlo-49026530')
cy.contains(teacher2)
cy.contains('Syksy 2023').click()
cy.contains('Kevät 2019').click()
cy.contains('tr', 'MAT12004').within(() => {
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
rowContent.forEach((content, index) => {
cy.get('td').eq(index).contains(content)
})
})
})
it('Check leaderboad works', () => {
cy.cs('Leaderboard').click()
cy.cs('academic-year').click()
cy.contains('2020-2021').click()
})
})
-101
View File
@@ -1,101 +0,0 @@
/// <reference types="cypress" />
const progressLevels = ['bachelors', 'bachelor-masters', 'masters', 'doctoral']
const checkProgressBarCharts = () => {
progressLevels.forEach(level => cy.cs(`faculty-${level}-progress-bar-chart-section`))
}
const checkProgressTables = () => {
progressLevels.forEach(level => cy.cs(`${level}-faculty-progress-table`))
}
const graduationTimesLevels = ['bachelor', 'bcMsCombo', 'master', 'doctor']
const checkGraduationCharts = mode => {
graduationTimesLevels.forEach(level => {
cy.cs(`${level}-${mode}-bar-chart`).within(() => {
// Check that bar has loaded
cy.contains('Loading content').should('not.exist')
})
})
}
describe('University view', { defaultCommandTimeout: 60 * 1000 }, () => {
beforeEach(() => {
cy.init('/university')
cy.contains('University')
})
describe('Faculty progress tab', () => {
it('contains all the correct progress bar charts', () => {
checkProgressBarCharts()
})
it('contains all the correct progress tables', () => {
checkProgressTables()
})
it("'All study rights / Special study rights excluded' toggle works", () => {
cy.cs('study-right-toggle').click()
checkProgressBarCharts()
checkProgressTables()
})
// Nothing in this test is explained. Non of these values are verified.
it.skip('years in the tables can be clicked to show faculty level breakdown', () => {
cy.cs('study-right-toggle').click()
cy.cs('bachelors-faculty-progress-table-show-button3').click()
cy.contains('29.5%').hover()
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
cy.contains('H50')
cy.contains('0 Credits: 0')
cy.contains('1 ≤ Credits < 45: 13')
cy.contains('45 ≤ Credits < 90: 10')
cy.contains('90 ≤ Credits < 135: 16')
cy.contains('135 ≤ Credits < 180: 3')
cy.contains('180 ≤ Credits: 2')
})
it('info boxes contain correct information', () => {
cy.cs('faculty-progress-info-box-button').click()
cy.cs('faculty-progress-info-box-content').contains('Kuvaa tiedekuntaan kuuluvien')
cy.cs('faculty-bachelor-masters-progress-info-box-button').click()
cy.cs('faculty-bachelor-masters-progress-info-box-content').contains('The starting year is the')
})
})
describe('Faculty graduations tab', () => {
beforeEach(() => {
cy.cs('faculty-graduations-tab').click()
})
describe('Different modes work', () => {
it('Breakdown', () => {
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-breakdown').click()
})
checkGraduationCharts('breakdown')
})
it('Median', () => {
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-median').click()
})
checkGraduationCharts('median')
})
it('Average', () => {
cy.cs('graduation-mode-selector').within(() => {
cy.cs('select-average').click()
})
checkGraduationCharts('average')
})
})
it('info boxes contain correct information', () => {
cy.cs('average-graduation-times-info-box-button').hover()
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset valmistumisajat')
})
})
})
-93
View File
@@ -1,93 +0,0 @@
/// <reference types="cypress" />
const visibleLinks = {
norights: ['University', 'Faculties', 'Special populations', 'Feedback'],
onlycoursestatistics: ['University', 'Courses', 'Special populations', 'Feedback'],
}
visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Programmes', 'Students']
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
const containsLinks = links => {
cy.cs('nav-bar').within(() => {
for (const link of links) {
cy.contains(link)
}
})
}
const userButtonWorks = (username, mocking = false) => {
cy.cs('nav-bar-user-button').click()
cy.contains(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)
cy.contains('Language')
cy.contains('suomi')
cy.contains('English')
cy.contains('svenska')
cy.contains(mocking ? 'Stop mocking' : 'Log out')
}
describe('Users tests', () => {
describe('Using as user with just grp-oodikone-user, no other rights', () => {
it('shows correct tabs', () => {
cy.init('', 'norights')
containsLinks(visibleLinks.norights)
userButtonWorks('norights')
})
})
describe('Using as coursestatistics user', () => {
it('shows correct tabs', () => {
cy.init('', 'onlycoursestatistics')
containsLinks(visibleLinks.onlycoursestatistics)
userButtonWorks('onlycoursestatistics')
})
})
describe('Using as basic user', () => {
it('shows correct tabs', () => {
cy.init('')
containsLinks(visibleLinks.basic)
userButtonWorks('basic')
})
})
describe('Using as admin', () => {
beforeEach(() => {
cy.init('/users', 'admin')
})
it('should see more stuff than others', () => {
containsLinks(visibleLinks.admin)
userButtonWorks('admin')
})
// TODO: This will be re-enabled when migrating to playwright
describe.skip('can mock as other users', { retries: 3 }, () => {
beforeEach(() => {
cy.cs('user-page-button-basic').click()
cy.cs('mock-button').click()
})
it('user button shows mocked user', () => {
userButtonWorks('basic', true)
})
it("only the mocked user's programmes are visible", () => {
cy.intercept('/api/populationstatistics/studyprogrammes').as('studyprogrammes')
cy.visit('/populations')
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
cy.contains('h6', 'Degree programme')
cy.cs('population-programme-selector').within(() =>
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
)
cy.cs('population-programme-selector-parent').click()
cy.cs('population-programme-selector-parent').within(() => cy.contains('Matemaattisten tieteiden kandiohjelma'))
cy.cs('population-programme-selector-parent').within(() =>
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
)
})
})
})
})
@@ -1,4 +0,0 @@
{
"studentSet1": ["433237", "457144", "458090", "474270", "479440"],
"coursesSet1": ["TKT10001", "TKT10002", "TKT10003", "TKT10004"]
}
-4
View File
@@ -1,4 +0,0 @@
{
"studentNumbersForCSStudentsSet1": ["433237", "457144", "458090", "465136"],
"studentNumbersForCSStudentsSet2": ["474270", "479440", "470391", "474789"]
}
-4
View File
@@ -1,4 +0,0 @@
{
"courseCodesSet1": ["TKT10001", "TKT10002"],
"courseCodesSet2": ["TKT10003", "TKT10004", "TKT10005"]
}
-28
View File
@@ -1,28 +0,0 @@
[
{
"id": "sgg-cypress-1",
"name": {
"fi": "MAT kandit 2020"
},
"members": [
{ "personStudentNumber": "433237" },
{ "personStudentNumber": "457144" },
{ "personStudentNumber": "458090" }
],
"tags": {
"studyProgramme": "KH50_001",
"year": "2020"
}
},
{
"id": "sgg-cypress-2",
"name": {
"fi": "Oma ohjausryhmä"
},
"members": [{ "personStudentNumber": "474270" }, { "personStudentNumber": "479440" }],
"tags": {
"studyProgramme": null,
"year": null
}
}
]
-34
View File
@@ -1,34 +0,0 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
on('before:browser:launch', (browser = {}, launchOptions) => {
// By manually setting the browser width and height in headless
// mode, screenshots and videos will have a better quality
// https://www.cypress.io/blog/generate-high-resolution-videos-and-screenshots
const width = 2560
const height = 1440
if (browser.name === 'chrome' && browser.isHeadless) {
launchOptions.args.push(`--window-size=${width},${height}`)
launchOptions.args.push('--force-device-scale-factor=1')
}
return launchOptions
})
}
-198
View File
@@ -1,198 +0,0 @@
/// <reference types="cypress" />
//
// ! IMPORTANT: here we need to set keys to be all lowercase, since
// ! we're replacing headers after they've left browser / frontend.
// All of these users are available in anon user-db
const adminUserHeaders = {
uid: 'admin',
displayname: 'Admin User',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'grp-oodikone-users;grp-oodikone-basic-users;grp-toska',
mail: 'grp-toska+mockadminuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-6666666',
}
const basicUserHeaders = {
uid: 'basic',
displayname: 'Basic User',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'grp-oodikone-users;grp-oodikone-basic-users',
mail: 'grp-toska+mockbasicuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-1234567',
}
const onlycoursestatisticsUserHeaders = {
uid: 'onlycoursestatistics',
displayname: 'Onlycoursestatistics User',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'grp-oodikone-basic-users',
mail: 'grp-toska+mockonlycoursestatisticsuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-7654321',
}
const norightsUserHeaders = {
uid: 'norights',
displayname: 'Norights User',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'grp-oodikone-users',
mail: 'grp-toska+mocknorightuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-0000000',
}
const onlyIamRightsUserHeaders = {
uid: 'onlyiamrights',
displayname: 'Only IAM rights user',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'hy-employees;hy-mltdk-mat-jory',
mail: 'grp-toska+mockonlyiamrightsuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-1111111',
}
// Also has element detail of "KH50_005" for the study guidance groups to work
const onlyStudyGuidanceGroupsUser = {
uid: 'onlystudyguidancegroups',
displayname: 'Study Guidance Groups User',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'grp-oodikone-users',
mail: 'grp-toska+mockonlystudyguidancegroupsuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-2222222',
}
export const userHeaders = [
adminUserHeaders,
basicUserHeaders,
onlycoursestatisticsUserHeaders,
norightsUserHeaders,
onlyIamRightsUserHeaders,
onlyStudyGuidanceGroupsUser,
]
/**
* Set up headers to login, set up correct user (admin / basic / etc.) and open given path.
*/
Cypress.Commands.add('init', { prevSubject: false }, (path, userId = 'basic') => {
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
cy.intercept('*', req => {
req.headers = headersToUse
})
// TODO: Add wait for visit url.
// This is block by course population query.
cy.visit({ method: 'GET', url: path, headers: headersToUse })
})
/**
* Shorthand for using "Cypress Selectors" (CS), i.e., `data-cy` attributes.
*/
Cypress.Commands.add('cs', { prevSubject: 'optional' }, (subject, name) => {
const selector = `[data-cy="${name}"]`
if (subject === undefined) {
return cy.get(selector)
}
return cy.wrap(subject).find(selector)
})
/**
* Select item from FilterSelect dropdown.
* If the index is a number, select index-th item.
* Otherwise select the item containing index text.
*/
Cypress.Commands.add('selectFromDropdown', { prevSubject: false }, (label, index, isMultiSelect = false) => {
cy.cs(`${label}-selector`).click()
cy.get(`[role="listbox"][aria-labelledby="${label}"]`).within(() => {
if (typeof index === 'number') cy.get('li').eq(index).click()
else cy.get('li').contains(index).click()
})
if (isMultiSelect) cy.get('#menu- > .MuiModal-backdrop').click()
})
/**
* Select item from FilterSearchableSelect dropdown.
* If the index is a number, select index-th item.
* Otherwise select the item containing index text.
*/
Cypress.Commands.add('selectFromAutocomplete', { prevSubject: false }, (label, index) => {
cy.cs(`${label}-selector`).click()
cy.get(`#${label}-popper.MuiAutocomplete-popper [role="listbox"]`).within(() => {
if (typeof index === 'number') cy.get('li').eq(index).click()
else cy.get('li').contains(index).click()
})
})
/**
* Set range selector values FilterRange.
* If the reset flag is set, the function tries to reset the original values.
*/
Cypress.Commands.add('setRangeSelect', { prevSubject: false }, (filter, min, max, expected, reset) => {
const minText = min.toString()
const maxText = max.toString()
const expectedText = expected.toString()
const parent = `${filter}-filter-card`
cy.cs(parent)
.cs('FilterRangeStart')
.find('input')
.invoke('val')
.then(initialMin => {
cy.cs(parent)
.cs('FilterRangeEnd')
.find('input')
.invoke('val')
.then(initialMax => {
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
cy.cs(parent).cs('FilterRangeStart').find('input').type(minText)
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
cy.cs(parent).cs('FilterRangeEnd').find('input').type(maxText)
// This should be linked to other checking functions.
// Maybe one day.
cy.contains(`Students (${expectedText})`)
if (reset) {
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
cy.cs(parent).cs('FilterRangeStart').find('input').type(initialMin)
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
cy.cs(parent).cs('FilterRangeEnd').find('input')?.type(initialMax)
}
})
})
})
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
cy.get(`[data-cy=${tableName}-data-table] tbody`).within(() => {
correctStats.forEach((values, trIndex) => {
cy.get('tr')
.filter(':visible')
.eq(trIndex)
.within(() => {
values.forEach((value, tdIndex) => {
cy.get('td').eq(tdIndex).contains(value)
})
})
})
cy.get('tr').filter(':visible').should('have.length', correctStats.length)
})
})
/**
* Hover on the selected element.
*/
Cypress.Commands.add('hover', { prevSubject: 'element' }, (subject, options = {}) => {
cy.wrap(subject).trigger('mouseover', {
bubbles: true,
cancelable: true,
...options,
})
cy.wrap(subject).trigger('mousemove', {
bubbles: true,
cancelable: true,
...options,
})
})
-21
View File
@@ -1,21 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
require('./commands')
Cypress.on('uncaught:exception', error => {
/* returning false here prevents Cypress from failing the test */
return true
})
+19
View File
@@ -0,0 +1,19 @@
FROM postgres:15.10
# Remember to define this variable
ARG DB_NAME
ENV DB_NAME=$DB_NAME
ENV PGDATA=/data
ENV POSTGRES_USER=postgres
ENV POSTGRES_PASSWORD=postgres
ENV POSTGRES_HOST_AUTH_METHOD=trust
ENV POSTGRES_DB=$DB_NAME
# RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends pgcli
COPY ./.databasedumps/test/$DB_NAME.sql /docker-entrypoint-initdb.d/
EXPOSE 5432
HEALTHCHECK --interval=30s --timeout=5s --retries=10 --start-interval=3s --start-period=15s \
CMD pg_isready -U postgres -h 127.0.0.1
+24 -9
View File
@@ -4,11 +4,26 @@ x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
interval: 30s # Normal healthcehck interval
timeout: 5s
retries: 10 # Retries until SIGKILl
start_interval: 3s # How often healthcheck runs while starting
start_period: 30s # How long startup period is
services:
adminer:
<<: *defaults
container_name: oodikone-adminer
environment:
ADMINER_DESIGN: pepa-linha
image: adminer:5.5.1
ports:
- 5050:8080
profiles:
- adminer
volumes:
- ./adminer-plugins:/var/www/html/plugins-enabled
backend:
<<: *defaults
container_name: oodikone-backend
@@ -71,7 +86,7 @@ services:
ports:
- 4321:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
user-db:
@@ -85,7 +100,7 @@ services:
ports:
- 4320:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
redis:
@@ -112,7 +127,7 @@ services:
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
sis-importer-db:
@@ -124,7 +139,7 @@ services:
ports:
- 12346:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
jami:
@@ -145,14 +160,14 @@ services:
jami-db:
<<: *defaults
container_name: oodikone-jami-db
image: postgres:15.10
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/jami-db
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
ports:
- 5433:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
# This is still needed to make backend calls correctly route from localhost:5000/api to localhost:8080
+75
View File
@@ -0,0 +1,75 @@
# This compose file is used update test dbs
x-defaults: &defaults
restart: unless-stopped
volumes: !reset [] # Remove previous volume definitions
x-healthcheck-defaults: &healthcheck-defaults
test: ['CMD', 'pg_isready', '-U', 'postgres']
interval: 30s # Normal healthcehck interval
timeout: 5s
retries: 10 # Retries until SIGKILl
start_interval: 3s # How often healthcheck runs while starting
start_period: 15s # How long startup period is
services:
kone-db:
<<: *defaults
container_name: oodikone-kone-db
build:
context: .
dockerfile: db.Dockerfile
args:
DB_NAME: kone-db
environment:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
healthcheck:
<<: *healthcheck-defaults
user-db:
<<: *defaults
container_name: oodikone-user-db
build:
context: .
dockerfile: db.Dockerfile
args:
DB_NAME: user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
healthcheck:
<<: *healthcheck-defaults
sis-db:
<<: *defaults
container_name: oodikone-sis-db
build:
context: .
dockerfile: db.Dockerfile
args:
DB_NAME: sis-db
environment:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
shm_size: 2gb
healthcheck:
<<: *healthcheck-defaults
sis-importer-db:
<<: *defaults
container_name: oodikone-sis-importer-db
build:
context: .
dockerfile: db.Dockerfile
args:
DB_NAME: sis-importer-db
environment:
PGDATA: /data
POSTGRES_DB: sis-importer-db
POSTGRES_HOST_AUTH_METHOD: trust
healthcheck:
<<: *healthcheck-defaults
+6 -4
View File
@@ -4,9 +4,11 @@ x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
interval: 30s # Normal healthcehck interval
timeout: 5s
retries: 10 # Retries until SIGKILl
start_interval: 3s # How often healthcheck runs while starting
start_period: 30s # How long startup period is
services:
sis-db:
@@ -23,5 +25,5 @@ services:
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
+22 -14
View File
@@ -6,10 +6,18 @@ x-defaults: &defaults
x-build_defaults: &build_defaults
dockerfile: dev.Dockerfile
x-user_defaults: &user_defaults
# Run containers as user:group defined in .env
# If run as $(id -u):$(id -g) will reduce permission errors when mounting
# Defaults to registry.access.redhat.com/ubi10/nodejs-24-minimal's default user:group
user: '${DOCKER_UID:-1001}:${DOCKER_GID:-0}'
x-healthcheck-defaults: &healthcheck-defaults
interval: 1s
timeout: 3s
retries: 30
interval: 30s # Normal healthcehck interval
timeout: 5s
retries: 10 # Retries until SIGKILl
start_interval: 3s # How often healthcheck runs while starting
start_period: 30s # How long startup period is
services:
adminer:
@@ -17,14 +25,14 @@ services:
container_name: oodikone-adminer
environment:
ADMINER_DESIGN: pepa-linha
image: adminer:4.8.1
image: adminer:5.5.1
ports:
- 5050:8080
volumes:
- ./adminer-plugins:/var/www/html/plugins-enabled
backend:
<<: *defaults
<<: [*defaults, *user_defaults]
container_name: oodikone-backend
build:
dockerfile: backend/dev.Dockerfile
@@ -65,7 +73,7 @@ services:
<<: *healthcheck-defaults
frontend:
<<: *defaults
<<: [*defaults, *user_defaults]
container_name: oodikone-frontend
build:
dockerfile: frontend/dev.Dockerfile
@@ -94,7 +102,7 @@ services:
volumes:
- kone-db-data:/data
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
user-db:
@@ -108,7 +116,7 @@ services:
volumes:
- user-db-data:/data
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
redis:
@@ -134,7 +142,7 @@ services:
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
sis-importer-db:
@@ -148,7 +156,7 @@ services:
ports:
- 12346:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
jami:
@@ -171,7 +179,7 @@ services:
jami-db:
<<: *defaults
container_name: oodikone-jami-db
image: postgres:15.10
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/jami-db
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
@@ -180,11 +188,11 @@ services:
volumes:
- jami-data:/data
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
sis-updater-worker:
<<: *defaults
<<: [*defaults, *user_defaults]
container_name: oodikone-updater-worker
build:
<<: *build_defaults
@@ -211,7 +219,7 @@ services:
- sis-updater-worker-nodemod:/opt/app-root/src/node_modules
sis-updater-scheduler:
<<: *defaults
<<: [*defaults, *user_defaults]
container_name: oodikone-updater-scheduler
build:
<<: *build_defaults
-1
View File
@@ -5,7 +5,6 @@ A collection of useful tools, documentation and other resources.
## Code style and maintainability
- [ESLint documentation](https://eslint.org/docs/v8.x)
- [Prettier documentation](https://prettier.io/docs/en)
- [qlty.sh](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
- Qlty.sh replaced Code climate which was introduced by Riku
- The usefulness of qlty.sh is sometimes questionable
+175
View File
@@ -0,0 +1,175 @@
import { expect, test, type Page } from '@playwright/test'
import { init } from './support/commands'
const studentSet1 = ['433237', '457144', '458090', '474270', '479440']
const coursesSet1 = ['TKT10001', 'TKT10002', 'TKT10003', 'TKT10004']
const hasLanded = async (page: Page) => {
await expect(page.getByRole('button', { name: 'Search completed courses of students' })).toBeVisible()
await expect(
page.getByText(
'Here you can search by a list of student and course numbers to see whether students have completed certain courses yet'
)
).toBeVisible()
}
const openSearch = async (page: Page) => {
await init(page, '/completedcoursessearch')
await hasLanded(page)
}
const openCompletedCoursesModal = async (page: Page) => {
await page.getByRole('button', { name: 'Search completed courses of students' }).click()
}
const selectSavedCourselist = async (page: Page, name: string) => {
const searchInput = page.getByTestId('history-search').getByRole('combobox')
await searchInput.fill(name)
await page.getByRole('option', { name }).click()
}
const deleteAllPreviousSearches = async (page: Page) => {
const historySearch = page.getByTestId('history-search').getByRole('combobox')
while (await historySearch.isVisible()) {
await historySearch.click()
const options = page.getByRole('option')
if ((await options.count()) === 0) break
await options.first().click()
await page.getByRole('button', { name: 'Delete' }).click()
await expect(page.getByRole('button', { name: 'Delete' })).toBeDisabled()
}
await expect(page.getByText('You have no previous searches.')).toBeVisible()
}
const createCourseList = async (page: Page, courseCodes: string[], courseListName: string) => {
await openSearch(page)
await openCompletedCoursesModal(page)
await page.getByTestId('course-list-input').getByRole('textbox').fill(courseCodes.join('\n'))
await page.getByTestId('search-name').getByRole('textbox').fill(courseListName)
await page.getByRole('button', { name: 'Save' }).click()
}
const generateCourseListName = () => `TEST-course-list-${Date.now()}`
test.describe('When search modal is opened', () => {
test.afterAll(async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await openSearch(page)
await openCompletedCoursesModal(page)
if (await page.getByTestId('history-search').getByRole('combobox').isVisible()) {
await deleteAllPreviousSearches(page)
}
await context.close()
})
test('Modal opens correctly', async ({ page }) => {
await openSearch(page)
await openCompletedCoursesModal(page)
await expect(page.getByRole('dialog')).toContainText('Search completed courses of students')
await expect(
page.getByText('Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.')
).toBeVisible()
await expect(
page.getByText('Insert one or more courses, separated by a space, a newline, a comma, or a semicolon.')
).toBeVisible()
await expect(page.getByText('Insert name for this course list if you wish to save it')).toBeVisible()
})
test('Modal gets the correct course codes and student numbers from the URL', async ({ page }) => {
await init(
page,
'/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144'
)
await hasLanded(page)
await openCompletedCoursesModal(page)
await expect(page.getByTestId('student-no-input').getByRole('textbox')).toHaveValue('433237, 457144')
await expect(page.getByTestId('course-list-input').getByRole('textbox')).toHaveValue('TKT10001, TKT10002')
})
test('Returns empty table and notification for invalid input', async ({ page }) => {
await openSearch(page)
await openCompletedCoursesModal(page)
await page.getByTestId('student-no-input').getByRole('textbox').fill('1')
await page.getByTestId('course-list-input').getByRole('textbox').fill('1')
await page.getByRole('button', { name: 'Search' }).click()
await expect(page.getByTestId('rights-notification')).toContainText(
'The information for the following students could not be displayed'
)
await expect(page.getByTestId('rights-notification')).toContainText('1')
const table = page.getByTestId('completed-courses-table-div')
await expect(table).toContainText('Student number')
await expect(table.getByText('1', { exact: true })).toHaveCount(0)
})
test.describe('When a search with correct data is executed', () => {
test.beforeEach(async ({ page }) => {
await openSearch(page)
await openCompletedCoursesModal(page)
await page.getByTestId('student-no-input').getByRole('textbox').fill(studentSet1.join('\n'))
await page.getByTestId('course-list-input').getByRole('textbox').fill(coursesSet1.join('\n'))
await page.getByRole('button', { name: 'Search' }).click()
})
test('Pushes the query to url', async ({ page }) => {
await expect(page).toHaveURL(
new RegExp(
`/completedcoursessearch\\?courseList=${coursesSet1.join('&courseList=')}&studentList=${studentSet1.join('&studentList=')}`
)
)
})
test('Finds correct students and courses', async ({ page }) => {
const table = page.getByTestId('completed-courses-table-div')
for (const studentNumber of studentSet1) await expect(table).toContainText(studentNumber)
for (const courseCode of coursesSet1) await expect(table).toContainText(courseCode)
})
})
})
test.describe('Courselist saving-related functions work', () => {
test.beforeEach(async ({ page }) => {
await openSearch(page)
await openCompletedCoursesModal(page)
if (await page.getByTestId('history-search').getByRole('combobox').isVisible()) {
await deleteAllPreviousSearches(page)
} else {
await expect(page.getByText('You have no previous searches.')).toBeVisible()
}
})
test('Course list can be saved', async ({ page }) => {
const courseList = generateCourseListName()
const courses = ['CSM14204', 'TKT10004']
await createCourseList(page, courses, courseList)
await openSearch(page)
await openCompletedCoursesModal(page)
await selectSavedCourselist(page, courseList)
await expect(page.getByTestId('course-list-input').getByRole('textbox')).toHaveValue(courses.join(', '))
})
test('Course list can be deleted', async ({ page }) => {
const courseList = generateCourseListName()
await createCourseList(page, ['CSM14204', 'TKT10004'], courseList)
await openSearch(page)
await openCompletedCoursesModal(page)
await selectSavedCourselist(page, courseList)
await page.getByRole('button', { name: 'Delete' }).click()
await expect(page.getByText('You have no previous searches.')).toBeVisible()
})
test('Course list can be updated', async ({ page }) => {
const courseList = generateCourseListName()
await createCourseList(page, ['CSM14204', 'TKT10004'], courseList)
await openSearch(page)
await openCompletedCoursesModal(page)
await selectSavedCourselist(page, courseList)
await page.getByTestId('course-list-input').getByRole('textbox').pressSequentially(',TKT10001')
await page.getByRole('button', { name: 'Save' }).click()
await openSearch(page)
await openCompletedCoursesModal(page)
await selectSavedCourselist(page, courseList)
await expect(page.getByTestId('course-list-input').getByRole('textbox')).toHaveValue('CSM14204, TKT10004, TKT10001')
})
})
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
import { expect, test, type Page } from '@playwright/test'
import { init } from './support/commands'
const students1 = ['433237', '457144', '458090', '465136']
const students2 = ['474270', '479440', '470391', '474789']
const nonExistentStudentNumbers = ['123', 'X', '-']
const fillForm = async (page: Page, studentNumbers: string[], separator: string) => {
// NOTE: This helper uses pressSequentially instead of the normal fill so that it can append to a textfield
await page.getByTestId('student-number-input').getByRole('textbox').pressSequentially(studentNumbers.join(separator))
}
const search = async (page: Page) => {
await page.getByTestId('search-button').click()
}
const searchFor = async (page: Page, studentNumbers: string[], separator: string) => {
await fillForm(page, studentNumbers, separator)
await search(page)
}
const hasLanded = async (page: Page) => {
await expect(page.getByText('Credit accumulation')).toBeVisible()
await expect(page.getByText('Programme distribution')).toBeVisible()
await expect(page.getByText('Courses of population')).toBeVisible()
await expect(page.getByText('Students (4)')).toBeVisible()
}
const containsAmountOfStudents = async (page: Page, amount = 0) => {
await expect(page.getByText(`Credit accumulation (for ${amount} students)`)).toBeVisible()
await expect(page.getByText(`Students (${amount})`)).toBeVisible()
}
const containsSpecificStudents = async (page: Page, studentNumbers: string[]) => {
const studentsTab = page.getByText(`Students (${studentNumbers.length})`)
await studentsTab.click()
await Promise.all(studentNumbers.map(async number => await expect(page.getByText(number)).toBeVisible()))
}
const checkRightsNotification = async (page: Page, studentNumbers: string[]) => {
const notification = page.getByTestId('rights-notification')
await expect(notification).toContainText(
'The following students information could not be displayed. This could be either because they do not exist, or you do not have the right to view their information.'
)
await Promise.all(studentNumbers.map(number => expect(notification.locator('li').getByText(number)).toBeVisible()))
}
const fillName = async (page: Page) => {
const name = `TEST-${Date.now()}`
await expect(page.getByText('Insert a name for this custom population if you wish to save it')).toBeVisible()
await page.getByTestId('custom-population-name-input').getByRole('textbox').fill(name)
return name
}
const selectSavedPopulation = async (page: Page, name: string) => {
const searchInput = page.getByTestId('history-search').getByRole('combobox')
await searchInput.fill(name)
await searchInput.press('Enter')
}
const save = async (page: Page, name: string) => {
await page.getByRole('button', { name: 'Save' }).click()
// Wait for saved custom pop to be visible
await page.getByTestId('history-search').click()
await expect(page.getByText(name)).toBeVisible()
}
const deleteAllSearches = async (page: Page) => {
if (await page.getByTestId('history-search').isVisible()) {
const searchInput = page.getByTestId('history-search')
await searchInput.click()
const options = page.getByRole('cell')
while (await options.count()) {
await options.first().click()
await page.getByRole('button', { name: 'Delete' }).click()
await searchInput.click()
}
}
}
test.describe('Custom population tests', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/custompopulation')
await expect(page).toHaveURL(/\/custompopulation/)
await expect(page.getByRole('heading', { name: 'Custom population', exact: true })).toBeVisible()
})
test.afterAll(async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await init(page, '/custompopulation')
await deleteAllSearches(page)
await context.close()
})
test.describe('Custom population searching', () => {
test('Finds a proper population', async ({ page }) => {
await searchFor(page, students1, '\n')
await hasLanded(page)
await containsAmountOfStudents(page, students1.length)
await containsSpecificStudents(page, students1)
})
test("Doesn't return non-existing students", async ({ page }) => {
await searchFor(page, [...students1, ...nonExistentStudentNumbers], ' ')
await hasLanded(page)
await containsAmountOfStudents(page, students1.length)
await containsSpecificStudents(page, students1)
await checkRightsNotification(page, nonExistentStudentNumbers)
})
test("Doesn't find empty custom population", async ({ page }) => {
await searchFor(page, nonExistentStudentNumbers, ',')
await checkRightsNotification(page, nonExistentStudentNumbers)
await expect(page.getByText('Credit accumulation')).not.toBeVisible()
await expect(page.getByText('Programme distribution')).not.toBeVisible()
await expect(page.getByText('Courses of population')).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Back to search form' })).toBeVisible()
})
test("Doesn't return students user has no right to", async ({ page }) => {
const studentsForEduBachStudents = ['014990067', '013069465', '014853890']
await searchFor(page, studentsForEduBachStudents, ';;')
await checkRightsNotification(page, studentsForEduBachStudents)
await expect(page.getByText('Credit accumulation')).not.toBeVisible()
await expect(page.getByText('Programme distribution')).not.toBeVisible()
await expect(page.getByText('Courses of population')).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Back to search form' })).toBeVisible()
})
})
test.describe('Custom population by programme', () => {
test('Finds students of programmes matching a prefix', async ({ page }) => {
await page.getByTestId('search-mode').click()
await expect(page.getByTestId('search-button')).toBeDisabled()
await page.getByTestId('custom-population-programme-prefix-input').getByRole('textbox').fill('KH50')
await page.getByTestId('custom-population-add-by-prefix-button').click()
await expect(page.getByText('KH50_001')).toBeVisible()
await page.getByTestId('population-year-selector').click()
await page.getByText('2020 - 2021').click()
await page.getByTestId('search-button').click()
await expect(page.getByText('Credit accumulation')).toBeVisible()
await expect(page.getByText('Courses of population')).toBeVisible()
await expect(page.getByRole('button', { name: 'Back to search form' })).toBeVisible()
})
})
test.describe('Custom population search saving', () => {
test('Saves a custom population search', async ({ page }) => {
const name = await fillName(page)
await fillForm(page, students1, ',\n')
await save(page, name)
await selectSavedPopulation(page, name)
await search(page)
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
await containsAmountOfStudents(page, students1.length)
await containsSpecificStudents(page, students1)
await page.goto('/custompopulation')
await expect(page.getByRole('heading', { name: 'Custom population', exact: true })).toBeVisible()
await selectSavedPopulation(page, name)
await search(page)
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
await containsAmountOfStudents(page, students1.length)
await containsSpecificStudents(page, students1)
})
test('Updates a custom population search', async ({ page }) => {
const name = await fillName(page)
await fillForm(page, students1, ' ')
await save(page, name)
await selectSavedPopulation(page, name)
await search(page)
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
await containsAmountOfStudents(page, students1.length)
await containsSpecificStudents(page, students1)
await page.getByRole('button', { name: 'Back to search form' }).click()
await selectSavedPopulation(page, name)
await fillForm(page, ['\n', ...students2], ', ')
await save(page, name)
await page.goto('/custompopulation')
await expect(page.getByRole('heading', { name: 'Custom population', exact: true })).toBeVisible()
await selectSavedPopulation(page, name)
await search(page)
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
await containsAmountOfStudents(page, students1.length + students2.length)
await containsSpecificStudents(page, [...students1, ...students2])
})
})
})
+872
View File
@@ -0,0 +1,872 @@
import { test, expect, Page } from '@playwright/test'
import { checkTableStats, init, testMockTime } from './support/commands'
const getEmptyYears = (isAcademicYear = false) => {
const today = new Date(testMockTime)
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
const years = []
for (let year = latestYear; year >= 2024; year--) {
if (isAcademicYear) {
years.push(`${year} - ${year + 1}`)
} else {
years.push(year)
}
}
return years
}
const tagName = `tag-${new Date().getTime()}`
const selectYear = async (page: Page, year: number) => {
await page.getByLabel('Associated start year (optional)').fill(year.toString())
}
const deleteTag = async (page: Page, tagName: string) => {
await page.getByTestId(`delete-tag-${tagName}-button`).click()
await expect(page.getByText('Delete tag', { exact: true })).toBeVisible()
await expect(page.getByText('Are you sure you want to delete tag')).toBeVisible()
await page.getByTestId('confirm-delete-tag-button').click()
}
test.describe('Degree programme overview', () => {
test.describe('Degree programme selector', () => {
test('Degree programme search filter', async ({ page }) => {
await init(page, '/study-programme', 'admin')
await expect(page.getByText('Tietojenkäsittelytieteen kandiohjelma')).toBeVisible()
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
await page.getByTestId('study-programme-filter').click()
await page.getByPlaceholder('Type here to filter degree programmes').fill('Tietojenkäsittelytieteen')
await expect(page.getByText('Tietojenkäsittelytieteen kandiohjelma')).toBeVisible()
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).not.toBeVisible()
})
})
test.describe('Basic information tab works for basic user', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/study-programme')
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
await page.getByTestId('year-toggle').click() // NOTE: Tests are written for calendar years
})
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
test('Basic information tab loads', async ({ page }) => {
await expect(page.getByTestId('students-of-the-study-programme-section')).toBeVisible()
await expect(page.getByTestId('credits-produced-by-the-study-programme-section')).toBeVisible()
await expect(page.getByTestId('graduated-and-thesis-writers-of-the-programme-section')).toBeVisible()
await expect(page.getByTestId('programmes-before-or-after-section')).toBeVisible()
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
})
test('Basic information contains correct students', async ({ page }) => {
const years = getEmptyYears()
const tableContents = [
// [Year, Started studying, Accepted, Graduated, Cancelled, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0, 0]),
[2023, 8, 8, 26, 2, 0, 0],
[2022, 25, 26, 47, 0, 1, 3],
[2021, 29, 32, 48, 1, 0, 2],
[2020, 26, 27, 12, 0, 1, 3],
[2019, 28, 34, 1, 0, 0, 1],
[2018, 40, 45, 0, 0, 0, 1],
[2017, 41, 47, 0, 0, 0, 0],
]
await checkTableStats(page, tableContents, 'students-of-the-study-programme')
})
test('Basic information contains correct credits', async ({ page }) => {
const years = getEmptyYears()
const tableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
[2023, 1519, 1519, 0, 0, 0, 0, 0, 222, 0],
[2022, 3235, 3205, 0, 0, 30, 0, 0, 209, 0],
[2021, 5133, 5108, 0, 0, 25, 0, 0, 428, 25],
[2020, 5801, 5796, 0, 0, 5, 0, 0, 94, 10],
[2019, 5305, 5305, 0, 0, 0, 0, 0, 162, 0],
[2018, 3442, 3432, 0, 0, 10, 0, 0, 21, 0],
[2017, 1211, 1211, 0, 0, 0, 0, 0, 189, 0],
]
await checkTableStats(page, tableContents, 'credits-produced-by-the-study-programme')
})
test('Basic information contains correct thesis writers and graduates', async ({ page }) => {
const years = getEmptyYears()
const tableContents = [
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 26, 5],
[2022, 47, 13],
[2021, 48, 76],
[2020, 12, 23],
[2019, 1, 19],
[2018, 0, 0],
[2017, 0, 1],
]
await checkTableStats(page, tableContents, 'graduated-and-graduations-of-the-programme')
})
test('Special study rights can be excluded and basic data changes accordingly', async ({ page }) => {
await page.getByTestId('study-right-toggle').click()
const years = getEmptyYears()
const studentTableContents = [
// [Year, Started studying, Accepted, Graduated, Cancelled]
...years.map(year => [year, 0, 0, 0, 0]),
[2023, 8, 8, 24, 2],
[2022, 25, 26, 43, 0],
[2021, 29, 32, 47, 1],
[2020, 26, 27, 11, 0],
[2019, 28, 34, 1, 0],
[2018, 40, 45, 0, 0],
[2017, 41, 47, 0, 0],
]
await checkTableStats(page, studentTableContents, 'students-of-the-study-programme')
const graduatedTableContents = [
// [Year, Graduated, Wrote thesis]
...years.map(year => [year, 0, 0]),
[2023, 24, 5],
[2022, 43, 12],
[2021, 47, 69],
[2020, 11, 22],
[2019, 1, 18],
[2018, 0, 0],
[2017, 0, 1],
]
await checkTableStats(page, graduatedTableContents, 'graduated-and-graduations-of-the-programme')
})
test('Year can be changed to academic year, and data changes accordingly', async ({ page }) => {
await page.getByTestId('year-toggle').click()
const isAcademicYear = true
const years = getEmptyYears(isAcademicYear)
const studentTableContents = [
// [Year, Started studying, Accepted, Graduated, Cancelled, Transferred away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 4, 1, 0, 0],
['2022 - 2023', 25, 26, 35, 1, 0, 0],
['2021 - 2022', 29, 32, 58, 1, 1, 5],
['2020 - 2021', 26, 27, 30, 0, 0, 3],
['2019 - 2020', 28, 34, 7, 0, 1, 1],
['2018 - 2019', 40, 45, 0, 0, 0, 1],
['2017 - 2018', 41, 47, 0, 0, 0, 0],
]
await checkTableStats(page, studentTableContents, 'students-of-the-study-programme')
const creditTableContents = [
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 160, 160, 0, 0, 0, 0, 0, 67, 0],
['2022 - 2023', 2725, 2720, 0, 0, 5, 0, 0, 337, 0],
['2021 - 2022', 4092, 4042, 0, 0, 50, 0, 0, 198, 0],
['2020 - 2021', 5420, 5415, 0, 0, 5, 0, 0, 321, 25],
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 0, 101, 10],
['2018 - 2019', 4856, 4851, 0, 0, 5, 0, 0, 107, 0],
['2017 - 2018', 2350, 2345, 0, 0, 5, 0, 0, 26, 0],
]
await checkTableStats(page, creditTableContents, 'credits-produced-by-the-study-programme')
// await page.getByTestId('year-toggle').click()
})
test('Basic information graphs render', async ({ page }) => {
const graph = page.getByTestId('students-of-the-study-programme-line-graph-section')
await expect(graph.getByText('Started studying')).toBeVisible()
await expect(graph.getByText('Accepted')).toBeVisible()
await expect(graph.getByText('Graduated')).toBeVisible()
await expect(graph.getByText('Cancelled')).toBeVisible()
await expect(graph.getByText('Transferred away')).toBeVisible()
await expect(graph.getByText('Transferred to')).toBeVisible()
const creditsBarChart = page.getByTestId('credits-produced-by-the-study-programme-stacked-bar-chart-section')
await expect(creditsBarChart.getByText('Degree students')).toBeVisible()
await expect(creditsBarChart.getByText('Transferred')).toBeVisible()
await expect(creditsBarChart.getByText('5796')).toBeVisible()
await expect(creditsBarChart.getByText('428')).toBeVisible()
const thesisBarChart = page.getByTestId('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
await expect(thesisBarChart.getByText('Graduated students')).toBeVisible()
await expect(thesisBarChart.getByText('Wrote thesis')).toBeVisible()
await expect(thesisBarChart.getByText('47')).toBeVisible()
await expect(thesisBarChart.getByText('76')).toBeVisible()
await page.getByTestId('graduation-mode-selector').getByTestId('select-median').click()
await expect(page.getByTestId('unset-median-bar-chart').getByText('47 graduated')).toBeVisible()
await page.getByTestId('unset-median-bar-chart').getByText('47 graduated').hover()
await expect(page.locator('.grad-vals').getByText('47 students graduated in year 2022')).toBeVisible()
await expect(page.getByText('median study time: 6 semesters')).toBeVisible()
await expect(page.getByText('24 graduated on time')).toBeVisible()
await expect(page.getByText('12 graduated max year overtime')).toBeVisible()
await expect(page.getByText('11 graduated over year late')).toBeVisible()
const programmesBarChart = page.getByTestId('programmes-before-or-after-stacked-bar-chart-section')
await expect(programmesBarChart.getByText('Tietojenkäsittelytieteen maisteriohjelma')).toBeVisible()
await expect(programmesBarChart.getByText('Datatieteen maisteriohjelma')).toBeVisible()
await expect(programmesBarChart.getByText('Matematiikan ja tilastotieteen maisteriohjelma')).toBeVisible()
const row = page
.getByTestId('programmes-before-or-after-data-table')
.locator('tr')
.filter({ hasText: 'Matematiikan ja tilastotieteen maisteriohjelma' })
await expect(row.getByText('33')).toBeVisible()
await expect(row.getByText('20')).toBeVisible()
await expect(row.getByText('17')).toBeVisible()
})
})
test.describe('Graduation times of master programmes', () => {
test('are split into three graphs', async ({ page }) => {
await init(page, '/study-programme')
await page.getByRole('link', { name: 'Matematiikan ja tilastotieteen maisteriohjelma' }).click()
await page.getByTestId('year-toggle').click() // Tests are written for calendar year
await page.getByTestId('graduation-mode-selector').getByTestId('select-average').click()
const masterChart = page
.getByTestId('unset-graduation-times-section')
.filter({ hasText: 'Master study right', hasNotText: 'Bachelor +' })
await expect(masterChart).toBeVisible()
await expect(masterChart.getByText('2 graduated')).toBeVisible()
await masterChart.getByText('2 graduated').hover()
await expect(page.locator('.grad-vals').getByText('2 students graduated in year 2021')).toBeVisible()
await expect(page.getByText('average study time: 4.5 semesters')).toBeVisible()
await expect(page.getByText('1 graduated on time')).toBeVisible()
await expect(page.getByText('1 graduated max year overtime')).toBeVisible()
await expect(page.getByText('0 graduated over year late')).toBeVisible()
const bachelorMasterChart = page
.getByTestId('unset-graduation-times-section')
.filter({ hasText: 'Bachelor + master study right' })
await expect(bachelorMasterChart).toBeVisible()
await expect(bachelorMasterChart.getByText('11 graduated')).toBeVisible()
await bachelorMasterChart.getByText('11 graduated').hover()
await expect(page.locator('.grad-vals').getByText('11 students graduated in year 2023')).toBeVisible()
await expect(page.getByText('average study time: 11.18 semesters')).toBeVisible()
await expect(page.getByText('4 graduated on time')).toBeVisible()
await expect(page.getByText('6 graduated max year overtime')).toBeVisible()
await expect(page.getByText('1 graduated over year late')).toBeVisible()
})
})
test.describe('Study tracks and class statistics tab works for basic user', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/study-programme')
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
await page.getByTestId('StudyTracksAndClassStatisticsTab').click()
})
test.describe('Info boxes', () => {
test('Study track overview section', async ({ page }) => {
await page.getByTestId('study-track-overview-info-box-button').hover()
await expect(
page.getByTestId('study-track-overview-info-box-content').getByText('Opiskelijat, joiden')
).toBeVisible()
})
test('Progress of students section', async ({ page }) => {
await page.getByTestId('progress-of-students-info-box-button').hover()
await expect(
page.getByTestId('progress-of-students-info-box-content').getByText('Kuvaa koulutusohjelmassa')
).toBeVisible()
})
test('Average graduation times section', async ({ page }) => {
await page.getByTestId('average-graduation-times-info-box-button').click()
await expect(
page.getByTestId('average-graduation-times-info-box-content').getByText('Yksittäinen palkki')
).toBeVisible()
})
})
test('All sections are visible', async ({ page }) => {
await expect(page.getByTestId('study-track-selector-section')).toBeVisible()
await expect(page.getByTestId('study-track-overview-section')).toBeVisible()
await expect(page.getByTestId('progress-of-students-section')).toBeVisible()
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
})
test('Students of the degree programme are shown correctly', async ({ page }) => {
const isAcademicYear = true
const years = getEmptyYears(isAcademicYear)
const tableContents = [
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Cancelled, Has recent attainments, Men, Women, Other/Unknown, Finland, Other]
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 0, 0, 8, 0, 0, 0, 5, 3, 0, 8, 1],
['2022 - 2023', 26, 25, 0, 0, 24, 2, 1, 0, 19, 7, 0, 25, 1],
['2021 - 2022', 37, 29, 0, 0, 33, 4, 0, 0, 29, 8, 0, 35, 4],
['2020 - 2021', 30, 26, 0, 0, 11, 19, 0, 0, 15, 15, 0, 29, 3],
['2019 - 2020', 35, 28, 0, 0, 8, 27, 0, 0, 22, 13, 0, 35, 2],
['2018 - 2019', 46, 40, 0, 0, 6, 40, 1, 0, 26, 20, 0, 45, 1],
['2017 - 2018', 47, 41, 0, 0, 5, 42, 1, 0, 31, 16, 0, 47, 0],
['Total', 229, 197, 0, 0, 95, 134, 3, 0, 147, 82, 0, 224, 12],
]
await checkTableStats(page, tableContents, 'study-tracks-and-class-statistics')
})
test('Years in the students table can be expanded and study track data will be shown', async ({ page }) => {
const totalStats: [string, ...Array<number>] = ['2020 - 2021', 30, 26, 0, 0, 11, 19, 0, 0, 15, 15, 0, 29, 3]
const table = page.getByTestId('study-tracks-and-class-statistics-data-table').locator('tbody')
const row = table.locator('tr').filter({ hasText: '2020 - 2021' })
await expect(row.locator('td')).toHaveCount(14)
await expect(row.locator('td')).toHaveText(totalStats.map(n => n.toString()))
const studyTrackStats: [string, ...Array<number>][] = [
['Ekonometria (MAT-EKO)', 2, 2, 0, 0, 0, 2, 0, 0, 1, 1, 0, 2, 0],
['Matematiikka (MAT-MAT)', 13, 10, 0, 0, 1, 12, 0, 0, 7, 6, 0, 12, 3],
['Tietojenkäsittelyteoria (MAT-TIE)', 2, 1, 0, 0, 1, 1, 0, 0, 2, 0, 0, 2, 0],
['Tilastotiede (MAT-TIL)', 4, 4, 0, 0, 0, 4, 0, 0, 1, 3, 0, 4, 0],
]
await row.getByTestId('show-study-tracks-button').click()
await Promise.all(
studyTrackStats.map(async ([studyTrack, ...stats]) => {
await expect(page.getByText(studyTrack)).toBeVisible()
const studyTrackRow = table.locator('tr').filter({ hasText: studyTrack })
await expect(studyTrackRow.locator('td')).toHaveCount(14)
await expect(studyTrackRow.locator('td')).toHaveText([studyTrack, ...stats].map(n => n.toString()))
})
)
await row.getByTestId('show-study-tracks-button').click()
await Promise.all(
studyTrackStats.map(async ([studyTrack]) => {
await expect(page.getByText(studyTrack)).not.toBeVisible()
})
)
})
test.describe('Population link button works for', () => {
test('a single year', async ({ page }) => {
await page.getByTestId('2023-population-link-button').click()
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
await expect(page.getByText('Class of 2023 - 2024, 8 students')).toBeVisible()
})
test('total', async ({ page }) => {
await page.getByTestId('total-population-link-button').click()
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
await expect(page.getByText('Class of 2017 - 2026, 227 students')).toBeVisible()
})
test('Links to class statistics page with study track info included work', async ({ page }) => {
const table = page.getByTestId('study-tracks-and-class-statistics-data-table').locator('tbody')
const row = table.locator('tr').filter({ hasText: '2022 - 2023' })
await row.getByTestId('show-study-tracks-button').click()
await table.locator('tr').filter({ hasText: 'Matematiikka (MAT-MAT)' }).getByRole('link').click()
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
await expect(page.getByText('Studytrack MAT-MAT')).toBeVisible()
await expect(page.getByText('Class of 2022 - 2023, 26 students')).toBeVisible()
await expect(page.getByText('Showing 3 out of 26 students')).toBeVisible()
})
})
test('Student progress data is shown correctly', async ({ page }) => {
const years = getEmptyYears(true)
const tableContents = [
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits, Graduated]
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0, 0],
['2022 - 2023', 26, 9, 8, 4, 3, 0, 0, 0, 2],
['2021 - 2022', 37, 8, 4, 9, 8, 4, 0, 0, 4],
['2020 - 2021', 30, 2, 1, 2, 5, 0, 0, 1, 19],
['2019 - 2020', 35, 1, 0, 2, 1, 0, 0, 4, 27],
['2018 - 2019', 46, 0, 1, 1, 2, 0, 0, 2, 40],
['2017 - 2018', 47, 0, 1, 3, 0, 0, 1, 0, 42],
['Total', 229, 28, 15, 21, 19, 4, 1, 7, 134],
]
await checkTableStats(page, tableContents, 'study-programme-progress')
})
test('Progress section', async ({ page }) => {
const barChart = page.getByTestId('programme-progress-bar-chart-section')
await expect(barChart.getByText('Less than 30 credits')).toBeVisible()
await expect(barChart.getByText('30–60 credits')).toBeVisible()
await expect(barChart.getByText('At least 180 credits')).toBeVisible()
await expect(barChart.getByText('58.5%')).toBeVisible() // The percentage for total graduated, to check that the graph renders
await page.getByTestId('programme-progress-bar-chart-section').getByText('58.5%').hover()
await expect(page.getByText('Graduated: 134')).toBeVisible()
})
test.describe('Average graduation times section', () => {
test('Shows correct data for all years and one year', async ({ page }) => {
// NOTE: This test could be flaky. Many nth and first/last used
await expect(page.getByTestId('unset-graduation-times-section').getByText('2019 - 2020')).toBeVisible()
await page.getByTestId('unset-graduation-times-section').getByText('21', { exact: true }).hover()
await expect(page.getByText('On time: 21')).toBeVisible()
await expect(
page
.getByTestId('unset-graduation-times-section')
.getByText('Click any bar on the chart to open a study track level breakdown for that year')
).toBeVisible()
await page.getByTestId('unset-graduation-times-section').getByText('15', { exact: true }).nth(1).click() // Skip legend
await expect(
page
.getByTestId('unset-graduation-times-section')
.getByText('Click any bar on the chart to open a study track level breakdown for that year')
).not.toBeVisible()
await expect(page.getByText('Year 2020 - 2021 by start year')).toBeVisible()
await expect(page.getByText('MAT-MAT')).toBeVisible()
await page.getByTestId('unsetBreakdownBarChartFaculty').getByText('9', { exact: true }).hover()
await expect(page.getByText('Matematiikka')).toBeVisible()
await expect(page.getByText('MAT-MAT').last()).toBeVisible()
await expect(page.getByText('On time: 9')).toBeVisible()
})
test('Shows correct average', async ({ page }) => {
await page.getByTestId('select-average').click()
const section = page.getByTestId('average-graduation-times-section')
await expect(section.getByText('2020 - 2021')).toBeVisible()
await section.getByText('19 graduated').hover()
await expect(page.getByText('From class of 2020 - 2021, 19/30 students have graduated')).toBeVisible()
await expect(page.getByText('average study time: 5.79 semesters')).toBeVisible()
await expect(page.getByText('15 graduated on time')).toBeVisible()
await expect(page.getByText('3 graduated max year overtime')).toBeVisible()
await expect(page.getByText('1 graduated over year late')).toBeVisible()
})
})
test.describe('Study track can be changed', () => {
test.beforeEach(async ({ page }) => {
// page.waitForTimeout('@stQuery')
await expect(page.getByText('All students of the programme')).toBeVisible()
await page.getByTestId('study-track-select').click()
await expect(page.getByText('Ekonometria')).toBeVisible()
await expect(page.getByText('Tietojenkäsittelyteoria')).toBeVisible()
await expect(page.getByText('Tilastotiede')).toBeVisible()
await page.getByText('Matematiikka').click()
})
test('Students of the study track are shown correctly', async ({ page }) => {
await expect(
page
.getByTestId('study-track-overview-section')
.getByText('Students of the study track MAT-MAT by starting year')
).toBeVisible()
const tableContents = [
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Cancelled, Has recent attainment, Men, Women, Other/Unknown, Finland, Other]
['2022 - 2023', 3, 3, 0, 0, 1, 2, 1, 0, 2, 1, 0, 3, 0],
['2021 - 2022', 4, 1, 0, 0, 1, 3, 0, 0, 3, 1, 0, 4, 0],
['2020 - 2021', 13, 10, 0, 0, 1, 12, 0, 0, 7, 6, 0, 12, 3],
['2019 - 2020', 17, 14, 0, 0, 0, 17, 0, 0, 10, 7, 0, 17, 1],
['2018 - 2019', 24, 21, 0, 0, 2, 22, 1, 0, 11, 13, 0, 24, 0],
['2017 - 2018', 28, 24, 0, 0, 1, 27, 1, 0, 15, 13, 0, 28, 0],
['Total', 89, 73, 0, 0, 6, 83, 3, 0, 48, 41, 0, 88, 4],
]
await checkTableStats(page, tableContents, 'study-tracks-and-class-statistics')
})
test('Links to class statistics page with study track info included work', async ({ page }) => {
await page.getByTestId('2020-population-link-button').click()
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
await expect(page.getByText('Studytrack MAT-MAT')).toBeVisible()
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
await expect(page.getByText('Showing 10 out of 30 students')).toBeVisible()
})
// FIXME: This feature was implemented. The data is no-good, so maybe reimplement this when it's clean.
test.fixme('Info message about missing progress stats is displayed', async ({ page }) => {
await expect(page.getByText('Progress of students of the study track MAT-MAT by starting year')).toBeVisible()
await expect(
page.getByText('Progress data is currently only available for all students of the degree programme.')
).toBeVisible()
})
})
})
test.describe('Programme courses tab works for basic user', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/study-programme')
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
await page.getByTestId('ProgrammeCoursesTab').click()
await expect(page.getByTestId('by-credit-type-section')).toBeVisible()
await expect(page.getByTestId('by-credit-type-tab')).toBeVisible()
await expect(page.getByTestId('by-semester-tab')).toBeVisible()
})
test.describe('By credit type tab', () => {
test.beforeEach(async ({ page }) => {
await expect(page.getByText('Programme courses by credit type')).toBeVisible()
await expect(page.getByText('Loading content')).not.toBeVisible()
await page.getByText('Total students').click()
})
test('shows correct data', async ({ page }) => {
await expect(
page
.locator('tbody')
.locator('tr')
.filter({ hasText: 'Johdatus yliopistomatematiikkaan', hasNotText: 'Avoin yo' })
.locator('td')
).toHaveText(
['Course', 'MAT11001', 'Johdatus yliopistomatematiikkaan', 272, 253, 19, 240, 0, 4, 0, 0, 0, 1, 9].map(n =>
n.toString()
)
)
await expect(
page.locator('tbody').locator('tr').filter({ hasText: 'Raja-arvot', hasNotText: 'Avoin yo' }).locator('td')
).toHaveText(
['Course', 'MAT11003', 'Raja-arvot', 270, 249, 21, 237, 0, 4, 0, 0, 0, 1, 8].map(n => n.toString())
)
await expect(
page
.locator('tbody')
.locator('tr')
.filter({ hasText: 'Differentiaalilaskenta', hasNotText: 'Avoin yo' })
.locator('td')
).toHaveText(
['Course', 'MAT11004', 'Differentiaalilaskenta', 262, 248, 14, 230, 0, 4, 0, 0, 0, 1, 14].map(n =>
n.toString()
)
)
})
test('year toggle works', async ({ page }) => {
await page.getByTestId('year-toggle').click()
await expect(
page
.locator('tbody')
.locator('tr')
.filter({ hasText: 'Johdatus yliopistomatematiikkaan', hasNotText: 'Avoin yo' })
.locator('td')
).toHaveText(
['Course', 'MAT11001', 'Johdatus yliopistomatematiikkaan', 278, 259, 19, 240, 0, 4, 0, 0, 0, 1, 15].map(n =>
n.toString()
)
)
await expect(
page.locator('tbody').locator('tr').filter({ hasText: 'Raja-arvot', hasNotText: 'Avoin yo' }).locator('td')
).toHaveText(
['Course', 'MAT11003', 'Raja-arvot', 274, 253, 21, 237, 0, 4, 0, 0, 0, 1, 12].map(n => n.toString())
)
await expect(
page
.locator('tbody')
.locator('tr')
.filter({ hasText: 'Differentiaalilaskenta', hasNotText: 'Avoin yo' })
.locator('td')
).toHaveText(
['Course', 'MAT11004', 'Differentiaalilaskenta', 264, 250, 14, 230, 0, 4, 0, 0, 0, 1, 16].map(n =>
n.toString()
)
)
})
test('time range selection works', async ({ page }) => {
await page.getByTestId('from-year-select').click()
await page.getByTestId('from-year-select-option-2020').click()
await page.getByTestId('to-year-select').click()
await page.getByTestId('to-year-select-option-2021').click()
await expect(
page
.locator('tbody')
.locator('tr')
.filter({ hasText: 'Vektorianalyysi I', hasNotText: 'Avoin yo' })
.filter({ hasNotText: 'Vektorianalyysi II' })
.locator('td')
).toHaveText(
['Course', 'MAT21003', 'Vektorianalyysi I', 104, 97, 7, 89, 0, 1, 0, 0, 0, 0, 10].map(n => n.toString())
)
await expect(
page.locator('tbody').locator('tr').filter({ hasText: 'Sarjat', hasNotText: 'Avoin yo' }).locator('td')
).toHaveText(['Course', 'MAT21002', 'Sarjat', 83, 77, 6, 70, 0, 1, 0, 0, 0, 0, 7].map(n => n.toString()))
await expect(
page.locator('tbody').locator('tr').filter({ hasText: 'Raja-arvot', hasNotText: 'Avoin yo' }).locator('td')
).toHaveText(['Course', 'MAT11003', 'Raja-arvot', 80, 73, 7, 69, 0, 2, 0, 0, 0, 1, 1].map(n => n.toString()))
})
test("'Show credits/Show students' toggle works", async ({ page }) => {
const headers = page.getByRole('columnheader')
await expect(headers).toHaveCount(17)
await expect(headers).toHaveText([
'Type',
'Code',
'Name',
'Total students',
'Breakdown of total',
'Breakdown of passed',
'Not included in total or passed',
'Passed',
'Not completed',
'Degree students',
'Open university students (with hetu)',
'Open university students (without hetu)',
'Exchange students',
'Other university students',
'Separate studies',
'Other students',
'Students with transferred credits',
])
await page.getByTestId('show-credits-students-toggle').click()
await expect(headers).toHaveCount(13)
await expect(headers).toHaveText([
'Type',
'Code',
'Name',
'Total credits',
'Credits produced by',
'Transferred credits',
'Degree students',
'Open university (hetu)',
'Open university (no hetu)',
'Exchange students',
'Other universities',
'Separate studies',
'Other',
])
})
})
test.describe('By semester tab', () => {
test.beforeEach(async ({ page }) => {
await page.getByTestId('by-semester-tab').click()
})
// TODO: Implement proper tests
test('can be opened', async ({ page }) => {
await expect(page.getByText('Programme courses by semester')).toBeVisible()
await expect(page.getByText('From')).toBeVisible()
await expect(page.getByText('Until')).toBeVisible()
})
})
})
test.describe('Degree courses tab works for basic user', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/study-programme')
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
await page.getByTestId('DegreeCoursesTab').click()
})
test.describe('Curriculum section', () => {
test('is shown in both settings tabs', async ({ page }) => {
for (const tab of ['criteria', 'visibility']) {
await page.getByTestId(`${tab}-tab`).click()
const panel = page.getByTestId(`${tab}-panel`)
await expect(panel.getByTestId('curriculum-section')).toBeVisible()
await expect(panel.getByText('Select curriculum to edit:')).toBeVisible()
await expect(panel.getByTestId('curriculum-picker').getByText('2023–2026')).toBeVisible()
}
})
test.skip('can be changed', () => {
return
})
})
test.describe('Credit criteria section', () => {
test('info box', async ({ page }) => {
await expect(page.getByTestId('credit-criteria-section')).toBeVisible()
await page.getByTestId('credit-criteria-info-box-button').click()
await expect(page.getByTestId('credit-criteria-info-box-content').getByText('Tässä asetetut')).toBeVisible()
})
test.skip('changing the credit criteria works', () => {
// TODO: Test using the inputs
// TODO: Button status
// TODO: Test "previously set to _" text changing when saving
})
})
test('Degree course table', async ({ page }) => {
await expect(page.getByTestId('criteria-panel').getByTestId('degree-course-table')).toBeVisible()
await page.getByTestId('visibility-tab').click()
await expect(page.getByTestId('visibility-panel').getByTestId('degree-course-table')).toBeVisible()
// TODO: Test clicking the arrow buttons
// TODO: Test toggling visibility for MODULES and COURSES
// TODO: Test changing criterion labels
})
})
test.describe('Tags tab works for basic user', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/study-programme')
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
await page.getByTestId('TagsTab').click()
// We need to wait for tag page to load or there is no delete buttons
await expect(page.getByText('Create new tag')).toBeVisible()
// Delete tags until none remain
const deleteButtons = page.locator('[data-cy^=delete-tag-]')
while ((await deleteButtons.count()) > 0) {
const count = await deleteButtons.count()
await deleteButtons.first().click()
await page.getByTestId('confirm-delete-tag-button').click()
// Helps playwright to wait for the deleted tag acually being removed from DOM
await expect(deleteButtons).toHaveCount(count - 1)
}
})
test('info box', async ({ page }) => {
await page.getByTestId('create-new-tag-info-box-button').click()
await expect(page.getByTestId('create-new-tag-info-box-content').getByText('Here you can create')).toBeVisible()
})
test.describe('Adding tags to populations and removing them works', () => {
test.beforeEach(async ({ page }) => {
await expect(page.getByText('This degree programme does not have any tags yet')).toBeVisible()
await page.getByTestId('tag-name-text-field').getByRole('textbox').fill(tagName)
})
test.afterEach(async ({ page }) => {
await deleteTag(page, tagName)
await expect(page.getByText('This degree programme does not have any tags yet')).toBeVisible()
})
test('can create and delete tags without start year', async ({ page }) => {
await page.getByTestId('create-button').click()
await expect(page.getByText(tagName)).toBeVisible()
await expect(page.getByText('No associated start year')).toBeVisible()
})
test('can create and delete tags with start year', async ({ page }) => {
await selectYear(page, 2022)
await page.getByTestId('create-button').click()
await expect(page.getByText(tagName)).toBeVisible()
await expect(page.getByText('Associated start year 2022')).toBeVisible()
})
test.fixme('population link works', async ({ page }) => {
await selectYear(page, 2022)
await page.getByTestId('create-button').click()
await expect(page.getByText(tagName)).toBeVisible()
await page.getByTestId('population-link-button').click()
// FIXME: If no students are tagged with the tag, it shows "Invalid tag id"
await expect(page.getByText(`Tagged with: ${tagName}`)).toBeVisible()
await page.goBack()
})
test('can create personal tags', async ({ page }) => {
await page.getByTestId('personal-tag-toggle').click()
await page.getByTestId('create-button').click()
await expect(page.getByText(tagName)).toBeVisible()
await expect(page.getByTestId(`${tagName}-visibility-icon`)).toBeVisible()
})
})
test.describe('Adding tags to students and removing them works', () => {
const studentInput = '477806,478275; 478953 479239\n 480080'
const studentNumbers = studentInput.match(/[^\s,;]+/g)!
test.beforeEach(async ({ page, context }) => {
await init(page, '/study-programme', 'basic', context)
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
await page.getByTestId('TagsTab').click()
})
test('can add tags to students', async ({ page, context }) => {
// NOTE: Navigating to each students' page takes time, fix?
test.slow()
test.setTimeout(120_000) // 2min
await page.getByTestId('tag-name-text-field').getByRole('textbox').fill(tagName)
await selectYear(page, 2022)
await page.getByTestId('create-button').click()
await expect(page.getByText(tagName)).toBeVisible()
await page.getByTestId('add-students-button').click()
await page.getByTestId('add-students-text-field').getByRole('textbox').fill(studentInput)
await page.getByTestId('add-students-confirm-button').click()
await expect(page.getByText('Students added to tag')).toBeVisible()
await page.getByTestId('population-link-button').click()
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
await expect(page.getByText('Class of 2022 - 2023')).toBeVisible()
await expect(page.getByText(`Tagged with: ${tagName}`)).toBeVisible()
await expect(page.getByText('Students (5)')).toBeVisible()
await page.getByText('Students (5)').click()
await Promise.all(studentNumbers.map(studentNumber => expect(page.getByText(studentNumber)).toBeVisible()))
await Promise.all(
studentNumbers.map(async studentNumber => {
const studentPage = await context.newPage()
await studentPage.goto(`/students/${studentNumber}`)
await expect(studentPage.getByRole('heading', { name: 'Tags' })).toBeVisible()
await expect(studentPage.getByText(tagName)).toBeVisible()
await studentPage.close()
})
)
await page.goBack()
await deleteTag(page, tagName)
})
test('deleting a tag from tag view also removes it from students', async ({ page, context }) => {
// NOTE: Navigating to each students' page takes time, fix?
test.slow()
test.setTimeout(180_000) // 4min
await expect(page.getByText(tagName)).not.toBeVisible()
await Promise.all(
studentNumbers.map(async studentNumber => {
const studentPage = await context.newPage()
await studentPage.goto(`/students/${studentNumber}`)
await expect(studentPage.getByRole('heading', { name: 'Tags' })).not.toBeVisible()
await expect(studentPage.getByText(tagName)).not.toBeVisible()
// await studentPage.close()
})
)
})
})
})
test.describe('IAM user', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/study-programme', 'onlyiamrights')
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
})
test.skip('year selector', () => {
// TODO: Implement this test
// Things to test:
// - Clicking the button works
// - Selecting a study track works
// - The button is disabled if only one year is selected
// - The slider is not visible if the user does not have correct programme rights
})
test('can access programme and correct tabs are visible', async ({ page }) => {
await expect(page.getByText('Basic information')).toBeVisible()
await expect(page.getByText('Study tracks and class statistics')).toBeVisible()
await expect(page.getByText('Update statistics')).not.toBeVisible()
await expect(page.getByText('Degree courses')).not.toBeVisible()
})
test('can access basic information', async ({ page }) => {
await page.getByTestId('BasicInformationTab').click()
await expect(page.getByTestId('students-of-the-study-programme-section')).toBeVisible()
await expect(page.getByTestId('credits-produced-by-the-study-programme-section')).toBeVisible()
await expect(page.getByTestId('graduated-and-thesis-writers-of-the-programme-section')).toBeVisible()
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
await expect(page.getByTestId('programmes-before-or-after-section')).toBeVisible()
})
test('can access study tracks', async ({ page }) => {
await page.getByTestId('StudyTracksAndClassStatisticsTab').click()
await expect(page.getByTestId('study-track-overview-section')).toBeVisible()
await expect(page.getByTestId('progress-of-students-section')).toBeVisible()
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
})
test("doesn't see other tabs", async ({ page }) => {
await expect(page.getByTestId('DegreeCoursesTab')).not.toBeVisible()
await expect(page.getByTestId('TagsTab')).not.toBeVisible()
await expect(page.getByTestId('UpdateStatisticsTab')).not.toBeVisible()
})
})
})
+195
View File
@@ -0,0 +1,195 @@
import { expect, Page, test } from '@playwright/test'
import { init } from './support/commands'
const facultyName = 'Matemaattis-luonnontieteellinen tiedekunta'
test.use({ acceptDownloads: true })
const openFaculty = async (page: Page, userId?: 'admin' | 'basic') => {
await init(page, '/faculties', userId)
await page.getByRole('link', { name: facultyName }).click()
}
test.describe('Faculty statistics', () => {
test.describe('Faculty list', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/faculties')
})
test('contains faculty names and faculty codes', async ({ page }) => {
const faculties = [
['Teologinen tiedekunta', 'H10'],
['Oikeustieteellinen tiedekunta', 'H20'],
['Lääketieteellinen tiedekunta', 'H30'],
['Humanistinen tiedekunta', 'H40'],
['Matemaattis-luonnontieteellinen tiedekunta', 'H50'],
['Farmasian tiedekunta', 'H55'],
['Bio- ja ympäristötieteellinen tiedekunta', 'H57'],
['Kasvatustieteellinen tiedekunta', 'H60'],
['Valtiotieteellinen tiedekunta', 'H70'],
['Svenska social- och kommunalhögskolan', 'H74'],
['Maatalous-metsätieteellinen tiedekunta', 'H80'],
['Eläinlääketieteellinen tiedekunta', 'H90'],
]
await expect(page.getByRole('heading', { name: 'Faculties', exact: true })).toBeVisible()
for (const [name, code] of faculties) {
await expect(page.getByText(name, { exact: true })).toBeVisible()
await expect(page.getByText(code, { exact: true })).toBeVisible()
}
})
test('contains a working link to faculty page', async ({ page }) => {
await page.getByRole('link', { name: 'Teologinen tiedekunta' }).click()
await expect(page).toHaveURL('/faculties/hy-org-1000000580')
await expect(page.getByText('Teologinen tiedekunta', { exact: true })).toBeVisible()
await expect(page.getByText('H10', { exact: true })).toBeVisible()
})
})
test.describe('Basic information tab', () => {
test('shows the correct tabs for an admin user', async ({ page }) => {
await openFaculty(page, 'admin')
const tabs = page.getByTestId('faculty-tabs')
await expect(tabs).toContainText('Basic information')
await expect(tabs).toContainText('Students by starting year')
await expect(tabs).toContainText('Graduation times')
await expect(tabs).toContainText('Update statistics')
})
test.describe('Basic user', () => {
test.beforeEach(async ({ page }) => {
await openFaculty(page)
})
test('info boxes contain the correct information', async ({ page }) => {
const infoBoxes = [
['students-of-the-faculty-info-box-button', 'students-of-the-faculty-info-box-content', 'Taulukon luvut on'],
[
'graduated-of-the-faculty-info-box-button',
'graduated-of-the-faculty-info-box-content',
'Sisältää kyseisenä',
],
[
'thesis-writers-of-the-faculty-info-box-button',
'thesis-writers-of-the-faculty-info-box-content',
'Sisältää kyseisenä',
],
[
'credits-produced-by-the-faculty-info-box-button',
'credits-produced-by-the-faculty-info-box-content',
'Sisältää opintopisteet',
],
]
for (const [button, content, text] of infoBoxes) {
await page.getByTestId(button).click()
await expect(page.getByTestId(content)).toContainText(text)
}
})
test('shows all graphs and tables', async ({ page }) => {
const sections = [
'students-of-the-faculty-line-graph-section',
'students-of-the-faculty-interactive-data-table',
'graduated-of-the-faculty-line-graph-section',
'graduated-of-the-faculty-interactive-data-table',
'thesis-writers-of-the-faculty-line-graph-section',
'thesis-writers-of-the-faculty-interactive-data-table',
'credits-produced-by-the-faculty-stacked-bar-chart-section',
'credits-produced-by-the-faculty-interactive-data-table',
]
for (const section of sections) await expect(page.getByTestId(section)).toBeVisible()
})
test('shows the correct tabs', async ({ page }) => {
const tabs = page.getByTestId('faculty-tabs')
await expect(tabs).toContainText('Basic information')
await expect(tabs).toContainText('Students by starting year')
await expect(tabs).toContainText('Graduation times')
await expect(tabs).not.toContainText('Update statistics')
})
test('year toggle works', async ({ page }) => {
const tables = [
'credits-produced-by-the-faculty-interactive-data-table',
'thesis-writers-of-the-faculty-interactive-data-table',
'students-of-the-faculty-interactive-data-table',
'graduated-of-the-faculty-interactive-data-table',
]
for (const table of tables) await expect(page.getByTestId(table)).toContainText('2022 - 2023')
await page.getByTestId('year-toggle').click()
for (const table of tables) await expect(page.getByTestId(table)).toContainText('2022')
})
test('programme toggle works', async ({ page }) => {
await expect(page.getByTestId('faculty-programmes-shown-info')).not.toBeVisible()
await page.getByTestId('programme-toggle').click()
await expect(page.getByTestId('faculty-programmes-shown-info')).toBeVisible()
})
test('study rights toggle works', async ({ page }) => {
await expect(page.getByTestId('faculty-exclude-specials-info')).not.toBeVisible()
await page.getByTestId('study-right-toggle').click()
await expect(page.getByTestId('faculty-exclude-specials-info')).toBeVisible()
})
})
})
test.describe('Students by starting year tab', () => {
test.beforeEach(async ({ page }) => {
await openFaculty(page)
await page.getByText('Students by starting year', { exact: true }).click()
})
test.skip('export button downloads an Excel file', async ({ page }) => {
// Firefox does not trigger playwright's download event when downloading a file
const downloadPromise = page.waitForEvent('download')
await page.getByTestId('faculty-student-table-export-button').click()
const download = await downloadPromise
expect(download.suggestedFilename()).toMatch(/^oodikone_H50_programme_stats_\d{4}-\d{2}-\d{2}\.xlsx$/)
})
test('export button exists and works', async ({ page }) => {
const exportButton = page.getByTestId('faculty-student-table-export-button')
await expect(exportButton).toBeVisible()
await expect(exportButton).toBeEnabled()
await exportButton.click()
})
test('info box contains the correct information', async ({ page }) => {
await page.getByTestId('faculty-student-table-info-box-button').click()
await expect(page.getByTestId('faculty-student-table-info-box-content')).toContainText('Opiskelijat, joiden')
})
test('percentage toggle works', async ({ page }) => {
const table = page.getByTestId('faculty-student-stats-table')
await expect(table).toBeVisible()
await expect(table).not.toContainText('92.3 %')
await page.getByTestId('percentage-toggle').click()
await expect(table).toContainText('92.3 %')
})
})
test.describe('Graduation times tab', () => {
test.beforeEach(async ({ page }) => {
await openFaculty(page)
await page.getByText('Graduation times', { exact: true }).click()
})
test('info box contains the correct information', async ({ page }) => {
await page.getByTestId('average-graduation-times-info-box-button').click()
await expect(page.getByTestId('average-graduation-times-info-box-content')).toContainText(
'Opiskelijoiden keskimääräiset'
)
})
test('graphs are visible', async ({ page }) => {
await expect(page.getByTestId('bachelor-graduation-times-section')).toBeVisible()
await expect(page.getByTestId('master-graduation-times-section')).toBeVisible()
await expect(page.getByTestId('doctor-graduation-times-section')).toBeVisible()
})
})
})
+198
View File
@@ -0,0 +1,198 @@
import { expect, test, type Page } from '@playwright/test'
import { init } from './support/commands'
const checkFilteredStudentCount = async (page: Page, count: number) => {
await expect(page.getByText(`Students (${count})`)).toBeVisible()
}
const notYetImplemented = () => {
test.skip(true, 'TODO')
}
const selectDropdownOption = async (page: Page, selector: string, option: string | number) => {
// Only open the filter card if it is closed
const dropdown = page.getByTestId(`${selector}-selector`)
if (!(await dropdown.isVisible())) {
await page.getByTestId(`${selector}-header`).click()
await expect(dropdown).toBeVisible()
}
await dropdown.click()
const options = page.getByRole('listbox').getByRole('option')
if (typeof option === 'number') await options.nth(option).click()
else await options.filter({ hasText: option }).click()
await page.keyboard.press('Escape') // Close the search box
}
const setRange = async (page: Page, filter: string, min: number, max: number, expected: number) => {
const card = page.getByTestId(`${filter}-filter-card`)
await card.getByTestId('FilterRangeStart').locator('input').fill(String(min))
await card.getByTestId('FilterRangeEnd').locator('input').fill(String(max))
await checkFilteredStudentCount(page, expected)
}
const pathToMathMSc2020 = '/populations?years=2020&programme=MH50_001&semesters=FALL&semesters=SPRING'
const defaultMScStudents = 26
test.describe("Population statistics with a master's programme", () => {
test.beforeEach(async ({ page }) => {
await init(page, pathToMathMSc2020)
})
test('Study track filter works', async ({ page }) => {
await selectDropdownOption(page, 'studyTrackFilter', 'Matematiikka ja soveltava matematiikka (MAST-MSM)')
await checkFilteredStudentCount(page, 15)
await page.getByTestId('studyTrackFilter-clear').click()
await checkFilteredStudentCount(page, defaultMScStudents)
})
test('Study right type filter works', async ({ page }) => {
const filter = page.getByTestId('studyRightTypeFilter-header')
await filter.click()
for (const [value, count] of [
['Bachelor + master', 24],
['Master only', 2],
['All', defaultMScStudents],
] as const) {
const option = page
.getByTestId('studyRightTypeFilter-filter-card')
.getByRole('radio', { name: value, exact: true })
await expect(option).not.toBeChecked()
await option.click()
await expect(option).toBeChecked()
await checkFilteredStudentCount(page, count)
}
await filter.click()
})
})
test.describe('Population Statistics', () => {
test.describe.skip('TODO', () => {
test('Study right type filter is not visible', async ({ page }) => {
await init(page, '/populations?years=2020&programme=KH50_001&semesters=FALL&semesters=SPRING')
await expect(page.getByText('Study right type')).not.toBeVisible()
})
test('Transfer filter works and is set to "not transferred" by default', notYetImplemented)
test('Graduation filter works', notYetImplemented)
test('Enrollment filter works', notYetImplemented)
test('Credit filter works', notYetImplemented)
test('Age filter works', notYetImplemented)
test('Gender filter works', notYetImplemented)
test('Starting year filter works', notYetImplemented)
test('Admission type filter works', notYetImplemented)
test('Courses filter works', notYetImplemented)
test('Filter combinations work', notYetImplemented)
test('"Reset All Filters" button works', notYetImplemented)
})
})
test.describe('Population Statistics with Bachelor + Master', () => {
test.beforeEach(async ({ page }) => {
await init(
page,
'/populations?years=2017&programme=KH50_001&showBachelorAndMaster=true&semesters=FALL&semesters=SPRING'
)
})
test('Graduation filter works', async ({ page }) => {
await page.getByTestId('graduatedFromProgrammeFilter-header').click()
for (const [value, count] of [
["Graduated with Bachelor's", 42],
["Graduated with Master's", 18],
["Not graduated with Bachelor's", 5],
["Not graduated with Master's", 29],
] as const) {
const option = page
.getByTestId('graduatedFromProgrammeFilter-filter-card')
.getByRole('radio', { name: value, exact: true })
await expect(option).not.toBeChecked()
await option.click()
await expect(option).toBeChecked()
await checkFilteredStudentCount(page, count)
}
})
test('Study right status filter works', async ({ page }) => {
await page.getByTestId('studyRightStatusFilter-header').click()
for (const [value, count] of [
['Active Bachelor study right', 0],
['Active Master study right', 0],
['Passive Bachelor study right', 5],
['Passive Master study right', 24],
] as const) {
const option = page
.getByTestId('studyRightStatusFilter-filter-card')
.getByRole('radio', { name: value, exact: true })
await expect(option).not.toBeChecked()
await option.click()
await expect(option).toBeChecked()
await checkFilteredStudentCount(page, count)
}
})
})
test.describe('Course Statistics', () => {
test('Grade filter works', notYetImplemented)
test('Programme filter works and defaults to "Attainment"', notYetImplemented)
test('Age filter works', notYetImplemented)
test('Gender filter works', notYetImplemented)
test('Starting year filter works', notYetImplemented)
test('Filter combinations work', notYetImplemented)
})
test.describe('Custom Population Statistics', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/custompopulation')
const students = ['433237', '457144', '458090', '465136', '474270', '479440', '470391', '474789']
await page.getByTestId('student-number-input').getByRole('textbox').pressSequentially(students.join('\n'))
await page.getByTestId('search-button').click()
await expect(page.getByText('Credit accumulation')).toBeVisible()
})
test('Age filter works', async ({ page }) => {
await page.getByTestId('ageFilter-header').click()
await setRange(page, 'ageFilter', 24, 28, 4)
})
test('Programme filter works and defaults to "Active Study Right"', async ({ page }) => {
await page.getByTestId('programmeFilter-header').click()
await expect(page.getByTestId('programmeFilter-mode-selector').getByRole('combobox')).toHaveText(
'Active Study Right'
)
await selectDropdownOption(page, 'programmeFilter', 'Matemaattisten tieteiden kandiohjelma')
await checkFilteredStudentCount(page, 5)
})
test('Gender filter works', async ({ page }) => {
for (const [value, count] of [
['Female (5)', 5],
['Male (3)', 3],
['Other (0)', 0],
['Unknown (0)', 0],
] as const) {
await selectDropdownOption(page, 'genderFilter', value)
await checkFilteredStudentCount(page, count)
}
})
test('Starting year filter works', async ({ page }) => {
await selectDropdownOption(page, 'startYearAtUniFilter', 0)
await checkFilteredStudentCount(page, 2)
})
test('Courses filter works', async ({ page }) => {
const filter = page.getByTestId('courseFilter-header')
await filter.click()
await page.getByRole('combobox').fill('MAT11005 - Integraalilaskenta')
await page.getByRole('option', { name: 'MAT11005 - Integraalilaskenta' }).click()
await checkFilteredStudentCount(page, 7)
// MAT11005 = hy-CU-117375607
await page.getByTestId('courseFilter-hy-CU-117375607-selector').click()
await page.getByRole('option', { name: 'Passed' }).click()
await checkFilteredStudentCount(page, 6)
})
})
+81
View File
@@ -0,0 +1,81 @@
import { expect, test, Page } from '@playwright/test'
import { init } from './support/commands'
const chooseSemester = async (page: Page, semester: string, fromOrTo: 'from' | 'to') => {
await page.getByTestId(`semester-${fromOrTo}`).click()
await page.getByTestId(`select-opt-${semester}`).click()
// The dropdown unmounts its options on close, so wait for it to be gone before reading the table
await expect(page.getByRole('listbox')).toHaveCount(0)
}
const checkNumbers = async (page: Page, numbers: number[], numberOfColumns: number, mode: string) => {
const totalRow = page.getByTestId(`ooditable-${mode}`).locator('table tbody tr').first()
const cells = totalRow.locator('td')
await expect(cells).toHaveCount(numberOfColumns)
await expect(cells).toHaveText(['Total' + 'All courses total'].concat(numbers.map(number => String(number))))
}
test.describe('When language center is opened', () => {
test.describe('as an admin user', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/languagecenterview', 'admin')
await page.getByTestId('completions-button').click()
})
test.describe('Faculties tab', () => {
test.beforeEach(async ({ page }) => {
await chooseSemester(page, 'Syksy 2017', 'from')
await chooseSemester(page, 'Kevät 2024', 'to')
})
test('Initial view is correct', async ({ page }) => {
await expect(page.getByText('All courses total')).toBeVisible()
await expect(
page.getByRole('cell', { name: 'AYKK-ENG Academic and Professional Communication in English' })
).toBeVisible()
await expect(page.getByText('AYKK-RUKIRJ')).toBeVisible()
})
// FIXME: This changes per updater run
// Example case: 550789 - KK-JAOK15
test.skip('Faculties tab shows numbers', async ({ page }) => {
await checkNumbers(page, [2074, 36, 9, 33, 758, 15, 40, 6, 51, 1, 24, 1059, 42, 0], 15, 'faculties')
})
test.skip('Faculties tab "exceeding" button works', async ({ page }) => {
await page.getByTestId('difference-button').click()
await checkNumbers(page, [66, 3, 0, 4, 18, 0, 1, 1, 4, 0, 0, 31, 3, 1], 15, 'faculties')
})
test.skip('Faculties tab semester selector changes numbers', async ({ page }) => {
await chooseSemester(page, 'Syksy 2020', 'from')
await checkNumbers(page, [1182, 28, 6, 27, 498, 4, 10, 2, 36, 0, 9, 535, 27, 0], 15, 'faculties')
})
})
test.describe('Semester tab', () => {
test.beforeEach(async ({ page }) => {
await page.getByText('By semesters').click()
await chooseSemester(page, 'Syksy 2017', 'from')
await chooseSemester(page, 'Kevät 2024', 'to')
})
test.skip('Semester tab shows numbers', async ({ page }) => {
await checkNumbers(page, [2074, 69, 26, 298, 58, 343, 98, 437, 138, 310, 90, 123, 73, 10, 1], 16, 'semesters')
})
})
})
test.describe('with a user with no rights', () => {
test('"Access denied" is shown', async ({ page }) => {
await init(page, '/languagecenterview', 'norights')
await expect(page.getByRole('heading', { name: 'Access denied' })).toBeVisible()
await expect(page.getByText("You don't currently have permission to view this page.")).toBeVisible()
await page.getByText('Special populations').click()
await expect(page.getByText('Language center view')).not.toBeVisible()
})
})
})
+252
View File
@@ -0,0 +1,252 @@
import { expect, test, type Page } from '@playwright/test'
import { init } from './support/commands'
type CourseData = [
courseOrModule: string,
code: string,
groupId: string,
name: string,
total: number,
passed: number,
notCompleted: number,
failed: number,
enrolledNoGrade: number,
]
const courseData: CourseData[] = [
['Course', 'MAT11001', 'hy-CU-117375151', 'Johdatus yliopistomatematiikkaan', 272, 253, 1 + 18, 1, 18],
['Course', 'MAT11003', 'hy-CU-117375413', 'Raja-arvot', 270, 249, 1 + 20, 1, 20],
['Course', 'MAT11004', 'hy-CU-117375487', 'Differentiaalilaskenta', 262, 248, 1 + 13, 1, 13],
['Course', 'MAT21002', 'hy-CU-117375793', 'Sarjat', 256, 250, 0 + 6, 0, 6],
['Course', 'MAT11005', 'hy-CU-117375607', 'Integraalilaskenta', 251, 234, 0 + 17, 0, 17],
['Course', 'MAT11002', 'hy-CU-117375394', 'Lineaarialgebra ja matriisilaskenta I', 247, 242, 1 + 4, 1, 4],
['Course', 'MAT12003', 'hy-CU-117376940', 'Todennäköisyyslaskenta I', 234, 227, 3 + 4, 3, 4],
['Course', 'MAT21001', 'hy-CU-117375754', 'Lineaarialgebra ja matriisilaskenta II', 230, 216, 4 + 10, 4, 10],
['Course', 'MAT21003', 'hy-CU-117375829', 'Vektorianalyysi I', 228, 202, 3 + 23, 3, 23],
['Course', 'MAT20005', 'hy-CU-120230575', 'Akateemiset taidot', 201, 178, 0 + 23, 0, 23],
['Module', 'MAT110', 'hy-SM-117412902', 'Matematiikka, perusopinnot', 195, 195, 0 + 0, 0, 0],
['Course', 'MAT21014', 'hy-CU-117377242', 'Johdatus logiikkaan I', 188, 170, 0 + 18, 0, 18],
]
const MAT21003_GROUP_ID = 'hy-CU-117375829'
const yearlyData = [
[2017, 1, 0, 1, 0, 1 + 0],
[2018, 31, 27, 4, 0, 4 + 0],
[2019, 58, 56, 2, 0, 2 + 0],
[2020, 63, 62, 1, 0, 1 + 0],
[2021, 41, 35, 0, 6, 0 + 6],
[2022, 28, 21, 0, 7, 0 + 7],
[2023, 21, 1, 0, 20, 0 + 20],
] as const
// Copied from services/backend/src/services/studyProgramme/studyProgrammeHelpers.ts (almost)
const getPercentage = (value: any, total: any) => {
if (typeof value !== 'number' || typeof total !== 'number') return 'NA'
if (total === 0) return 'NA'
if (value === 0) return '0.00 %'
return `${((value / total) * 100).toFixed(2)} %`
}
const selectCourseStatus = async (page: Page, courseGroupId: string, status: string) => {
await page.getByTestId(`courseFilter-${courseGroupId}-selector`).click()
await page.getByRole('listbox').getByRole('option', { name: status }).click()
}
const selectYear = async (page: Page, selector: string, option: string) => {
await page.getByTestId(selector).click()
await page.getByTestId(option).click()
}
test.describe('Numbers should match between', () => {
test.slow()
test.setTimeout(180_000) // 3min
test.describe('Programme courses, Course statistics and Course population (no substitutions, all years)', () => {
test('in Programme courses', async ({ page }) => {
await init(page, '/study-programme')
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
await page.getByTestId('ProgrammeCoursesTab').click()
await expect(page.getByText('Programme courses by credit type')).toBeVisible()
await page.getByRole('columnheader', { name: 'Total students' }).click()
const rows = page.locator('tbody tr')
for (const [index, course] of courseData.entries()) {
const row = rows.nth(index)
const [courseOrModule, code, , name, total, passed, notCompleted] = course
// Not optimal, but not checking all locator("td") matches directly with toHaveText is hard.
for (const [fieldIndex, field] of [courseOrModule, code, name, total, passed, notCompleted].entries()) {
await expect(row.locator('td').nth(fieldIndex)).toHaveText(String(field))
}
}
})
test('in Course statistics', async ({ page }) => {
for (const [
_courseOrModule,
_code,
groupId,
name,
total,
passed,
_notCompleted,
failed,
enrolledNoGrade,
] of courseData) {
await init(page, `/coursestatistics?courses=${groupId}&substitutions=false`, 'basic')
await expect(page).toHaveURL(
url =>
url.pathname === '/coursestatistics' &&
url.searchParams.get('courses') === groupId &&
url.searchParams.get('substitutions') === 'false'
)
await expect(page.getByText('Course statistics')).toBeVisible()
await expect(page.getByText(name)).toBeVisible()
await selectYear(page, 'FromYearSelector', 'FromYearSelectorOption2017-2018')
const row = page.locator('tbody tr').first()
await expect(row.locator('td')).toHaveText(
[
'Total',
total,
passed,
failed,
enrolledNoGrade,
getPercentage(passed, total),
getPercentage(failed + enrolledNoGrade, total),
].map(String)
)
}
})
test('in Course population', async ({ page }) => {
for (const [
_courseOrModule,
code,
groupId,
name,
total,
passed,
_notCompleted,
failed,
enrolledNoGrade,
] of courseData) {
await init(
page,
`/coursepopulation?from=${2017 - 1949}&to=${2023 - 1949}&courses=${groupId}&substitutions=false`,
'basic'
)
await expect(page).toHaveURL(
url =>
url.pathname === '/coursepopulation' &&
url.searchParams.get('courses') === groupId &&
url.searchParams.get('substitutions') === 'false'
)
await expect(page.getByText(name)).toBeVisible()
await page.getByTestId('courseFilter-filter-card').click()
await page.getByTestId('courseFilter-selector').click()
await page
.getByTestId('courseFilter-popper')
.getByRole('option', { name: `${code} - ${name}` })
.click()
for (const [status, expected] of [
['All', total],
['Passed', passed],
['Failed', failed],
['Enrolled, No Grade', enrolledNoGrade],
] as const) {
await selectCourseStatus(page, groupId, status)
await expect(page.getByText(`Students (${expected})`)).toBeVisible()
}
}
})
})
test.describe('Programme courses, Course statistics and Course population (no substitutions, single years, single course)', () => {
test('in Programme courses', async ({ page }) => {
await init(page, '/study-programme')
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
await page.getByTestId('ProgrammeCoursesTab').click()
for (const [year, total, passed, _failed, _enrolledNoGrade, notCompleted] of yearlyData) {
await selectYear(page, 'to-year-select', `to-year-select-option-${year}`)
await selectYear(page, 'from-year-select', `from-year-select-option-${year}`)
const row = page.locator('tbody tr').filter({ hasText: 'MAT21003' })
await expect(row.locator('td').nth(3)).toContainText(String(total))
await expect(row.locator('td').nth(4)).toContainText(String(passed))
await expect(row.locator('td').nth(5)).toContainText(String(notCompleted))
}
})
test('in Course statistics', async ({ page }) => {
for (const [year, total, passed, failed, enrolledNoGrade] of yearlyData) {
await init(page, `/coursestatistics?courses=${MAT21003_GROUP_ID}&substitutions=false`, 'basic')
await expect(page).toHaveURL(
url =>
url.pathname === '/coursestatistics' &&
url.searchParams.get('courses') === MAT21003_GROUP_ID &&
url.searchParams.get('substitutions') === 'false'
)
const yearString = `${year}-${year + 1}`
await selectYear(page, 'ToYearSelector', `ToYearSelectorOption${yearString}`)
await selectYear(page, 'FromYearSelector', `FromYearSelectorOption${yearString}`)
await expect(page.getByText('Student statistics')).toBeVisible()
await expect(page.locator('tbody tr')).toHaveCount(2)
const row = page.locator('tbody tr').filter({ hasText: 'Total' })
await expect(row.locator('td')).toHaveText(
[
'Total',
total,
passed,
failed,
enrolledNoGrade,
getPercentage(passed, total),
getPercentage(failed + enrolledNoGrade, total),
].map(String)
)
}
})
test('in Course population', async ({ page }) => {
for (const [year, _total, passed, failed, enrolledNoGrade] of yearlyData) {
await init(
page,
`/coursepopulation?from=${year - 1949}&to=${year - 1949}&courses=${MAT21003_GROUP_ID}&substitutions=false`,
'basic'
)
await expect(page).toHaveURL(
url =>
url.pathname === '/coursepopulation' &&
url.searchParams.get('courses') === MAT21003_GROUP_ID &&
url.searchParams.get('substitutions') === 'false'
)
await expect(page.getByText('Vektorianalyysi I')).toBeVisible()
await page.getByTestId('courseFilter-filter-card').click()
await page.getByTestId('courseFilter-selector').click()
await page
.getByTestId('courseFilter-popper')
.getByRole('option', { name: 'MAT21003 - Vektorianalyysi I' })
.click()
for (const [status, expected] of [
['All', _total],
['Passed', passed],
['Failed', failed],
['Enrolled, No Grade', enrolledNoGrade],
] as const) {
await selectCourseStatus(page, MAT21003_GROUP_ID, status)
await expect(page.getByText(`Students (${expected})`)).toBeVisible()
}
}
})
})
})
+493
View File
@@ -0,0 +1,493 @@
import { test, expect, Page } from '@playwright/test'
import { init } from './support/commands'
// Now "Class statistics" in UI
const selectStudyProgramme = async (page: Page, programme: string) => {
await expect(page.getByTestId('population-programme-selector').locator('input')).toHaveAttribute(
'placeholder',
'Select degree programme'
)
await page.getByTestId('population-programme-selector-parent').click()
await page.getByTestId('population-programme-selector-parent').getByText(programme).click()
}
const selectStudyTrack = async (page: Page, studyTrack: string) => {
await expect(page.getByTestId('population-studytrack-selector').locator('input')).toHaveAttribute(
'placeholder',
'Select study track'
)
await page.getByTestId('population-studytrack-selector-parent').click()
await page.getByTestId('population-studytrack-selector-parent').getByText(studyTrack).click()
}
const getPath = (programme: string) => {
return `/populations?months=49&semesters=FALL&semesters=SPRING&programme=${programme}&years=2020`
}
const pathToMathBSc2020 = getPath('KH50_001')
const pathToMathMSc2020 = getPath('MH50_001')
test.describe('Population statistics tests', () => {
test.describe('When using basic user', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/populations')
})
test.describe('Population search', () => {
test('Info box works', async ({ page }) => {
await expect(page.getByTestId('PopulationSearch-info-box-content')).not.toBeVisible()
await page.getByTestId('PopulationSearch-info-box-button').hover()
await expect(page.getByTestId('PopulationSearch-info-box-content')).toBeVisible()
await expect(
page.getByTestId('PopulationSearch-info-box-content').getByText('Tässä osiossa voi tarkastella')
).toBeVisible()
await page.getByTestId('PopulationSearch-info-box-button').dispatchEvent('mouseout')
await expect(page.getByTestId('PopulationSearch-info-box-content')).not.toBeVisible()
})
test('Form is usable', async ({ page }) => {
await expect(page.getByText('Search for class')).toBeVisible()
await expect(page.getByText('See class')).toBeDisabled()
const yearSelect = page.getByTestId('population-year-selector')
const yearDecrement = page.getByTestId('population-year-decrement')
const yearIncrement = page.getByTestId('population-year-increment')
await yearSelect.click()
await page.getByText('2018 - 2019').click()
await expect(yearSelect.getByText('2018 - 2019')).toBeVisible()
await yearDecrement.click()
await expect(yearSelect.getByText('2017 - 2018')).toBeVisible()
await yearIncrement.click()
await expect(yearSelect.getByText('2018 - 2019')).toBeVisible()
await expect(page.getByTestId('population-studytrack-selector').locator('input')).toHaveAttribute(
'placeholder',
'No study tracks available'
)
await selectStudyProgramme(page, 'Matematiikan ja tilastotieteen maisteriohjelma')
await expect(page.getByText('See class')).toBeEnabled()
await selectStudyTrack(page, 'Matematiikka ja soveltava matematiikka')
await expect(page.getByText('See class')).toBeEnabled()
})
test.describe('Correct population is shown for programme', () => {
test('without study tracks', async ({ page }) => {
await selectStudyProgramme(page, 'Matematiikan ja tilastotieteen maisteriohjelma')
// Select year 2020 - 2021
const yearSelect = page.getByTestId('population-year-selector')
await yearSelect.click()
await page.getByText('2020 - 2021').click()
await page.getByText('See class').click()
await expect(page.getByText('Matematiikan ja tilastotieteen maisteriohjelma')).toBeVisible()
await expect(page.getByText('Class of 2020 - 2021, 26 students')).toBeVisible()
await expect(page.getByText('Showing 26 out of 26 students')).toBeVisible()
await expect(page.getByText('Studytrack MAST-MSM')).not.toBeVisible()
})
test('with study tracks', async ({ page }) => {
await selectStudyProgramme(page, 'Matematiikan ja tilastotieteen maisteriohjelma')
// Select year 2020 - 2021
const yearSelect = page.getByTestId('population-year-selector')
await yearSelect.click()
await page.getByText('2020 - 2021').click()
await selectStudyTrack(page, 'Matematiikka ja soveltava matematiikka')
await page.getByText('See class').click()
await expect(page.getByText('Matematiikan ja tilastotieteen maisteriohjelma')).toBeVisible()
await expect(page.getByText('Class of 2020 - 2021, 26 students')).toBeVisible()
await expect(page.getByText('Showing 15 out of 26 students')).toBeVisible()
await expect(page.getByText('Studytrack MAST-MSM')).toBeVisible()
})
})
})
test('Population statistics is usable on general level', async ({ page }) => {
await page.goto(pathToMathBSc2020)
await expect(page.getByTestId('filtered-students')).toBeVisible()
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
await expect(page.getByText('Showing 27 out of 30 students')).toBeVisible()
await expect(page.getByText('Excludes exchange students')).toBeVisible()
await expect(page.getByText('Excludes students with non-degree study right')).toBeVisible()
await expect(page.getByText('Excludes students who have transferred out of this programme')).toBeVisible()
await expect(page.getByTestId('filtered-students')).toBeVisible()
})
test('Advanced settings work', async ({ page }) => {
await page.goto(pathToMathMSc2020)
await expect(page.getByText('Credit accumulation (for 26 students)')).toBeVisible()
await expect(page).toHaveURL(/.*semesters=FALL&semesters=SPRING.*/)
await page.getByTestId('advanced-toggle').click()
// only spring
await page.getByTestId('toggle-fall').click()
await page.getByText('Fetch class').click()
await expect(page.getByText('Credit accumulation (for 17 students)')).toBeVisible()
await expect(page).not.toHaveURL(/.*semesters=FALL.*/)
await page.getByTestId('advanced-toggle').click()
await page.getByTestId('toggle-fall').click()
await page.getByTestId('toggle-spring').click()
await page.getByText('Fetch class').click()
await expect(page.getByText('Credit accumulation (for 9 students)')).toBeVisible()
await expect(page).not.toHaveURL(/.*semesters=SPRING.*/)
await page.getByTestId('advanced-toggle').click()
await page.getByTestId('toggle-spring').click()
await page.getByText('Fetch class').click()
await expect(page.getByText('Credit accumulation (for 26 students)')).toBeVisible()
await expect(page).toHaveURL(/.*semesters=FALL&semesters=SPRING.*/)
})
test.describe('Credit statistics', () => {
test("'Credits gained' tab shows correct statistics for all students of the class and also students grouped by admission type", async ({
page,
}) => {
await page.goto(pathToMathBSc2020)
const totalStudents = 27
await expect(page.getByText('Credit statistics')).toBeVisible()
await page.getByText('Credit statistics').click()
await expect(page.getByTestId('credits-gained-tab')).toBeVisible()
await page.getByTestId('credits-gained-tab').click()
const limits = [1, 45, 90, 135, 180, null]
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
const getTableData = (selector: string, numbersOfStudents: number[]) => {
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
return ranges.map((range, index) => ({
selector: `credits-gained-table-${selector}`,
start: range[0],
end: range[1],
students: numbersOfStudents[index],
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
}))
}
await expect(page.getByTestId('credits-gained-table-All students of the class')).toBeVisible()
await expect(page.getByTestId('credits-gained-table-All students of the class').locator('th')).toHaveCount(4)
await expect(page.getByTestId('credits-gained-table-All students of the class').locator('th')).toContainText([
'',
'Credits gained between 01.08.2020 and ', // Current date
`Number of students` + `(n = ${totalStudents})`,
'Percentage of population',
])
await Promise.all(
[
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
].map(category =>
Promise.all(
category.map(async ({ selector, start, end, students, percentage }, index) => {
let value
if (start === null) {
value = '0 credits'
} else if (end === null) {
value = `${start} ≤ credits`
} else {
value = `${start} ≤ credits < ${end}`
}
const bodyLocator = page.getByTestId(selector).getByTestId('credits-gained-table-body')
await expect(bodyLocator).toBeVisible()
const row = bodyLocator.locator('tr').nth(index)
await expect(row.locator('td')).toHaveCount(4)
await expect(row.locator('td')).toHaveText(['' /* Icon */, value, students.toString(), percentage])
})
)
)
)
const numberOfStudentCounts = await page
.getByTestId('credits-gained-table-All students of the class')
.getByTestId('credits-gained-table-body')
.locator('td:nth-child(3)')
.allTextContents()
const sum = numberOfStudentCounts.reduce((acc, t) => acc + parseInt(t, 10), 0)
expect(sum).toBe(totalStudents)
})
test("'Statistics' tab shows correct statistics for all students of the class and also students grouped by admission type", async ({
page,
}) => {
await page.goto(pathToMathBSc2020)
await page.getByText('Credit statistics').click()
await page.getByTestId('credit-statistics-tab').click()
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
const categories = [
{
selector: 'All students of the population',
data: ['6136.50', '227.28', '197.00', '117.94', '60', '533'],
size: 27,
},
{
selector: 'Muu',
data: ['580.00', '193.33', '192.00', '36.75', '149', '239'],
size: 3,
},
{
selector: 'Todistusvalinta',
data: ['3657.50', '203.19', '193.50', '98.35', '60', '460'],
size: 18,
},
{
selector: 'Avoin väylä',
data: ['1899.00', '316.50', '257.00', '150.43', '150', '533'],
size: 6,
},
]
await Promise.all(
categories.map(async ({ selector, data, size }) => {
const tableLocator = page.getByTestId(`statistics-table-${selector}`)
await expect(tableLocator).toBeVisible()
await expect(tableLocator.getByRole('heading', { level: 5, name: selector })).toBeVisible()
await expect(tableLocator.getByTestId('credit-stats-population-size')).toContainText(`n = ${size}`)
await Promise.all(
rows.map((_, index) => {
const row = tableLocator.locator('table tbody tr').nth(index)
return expect(row.locator('td')).toHaveText([rows[index], data[index]])
})
)
})
)
})
})
test.describe('Courses of class', () => {
test('Is displayed and link to individual course stats page works and has substitutions enabled', async ({
page,
}) => {
await page.goto(pathToMathBSc2020)
await page.getByText('Courses of class').click()
await page.getByTestId('toggle-group-module-MAT110').click()
await page.getByRole('row', { name: 'MAT11001' }).getByRole('link').click()
await expect(page).toHaveURL(/.*coursestatistics.*/)
await expect(page.getByText('MAT11001', { exact: true })).toBeVisible()
await expect(page.getByText('AYMAT11001')).toBeVisible()
await expect(page.getByText('57033', { exact: true })).toBeVisible()
await expect(page.getByText('A57033')).toBeVisible()
})
test('Curriculum selection works', async ({ page }) => {
await page.goto(pathToMathBSc2020)
await page.getByText('Courses of class').click()
await expect(page.getByTestId('curriculum-picker')).toContainText('2020–2023')
await expect(page.getByTestId('toggle-group-module-MAT-tyo')).toBeVisible()
await page.getByTestId('curriculum-picker').click()
await page.getByText('2023–2026').click()
await expect(page.getByTestId('toggle-group-module-MAT-tyo')).not.toBeVisible()
})
test('Courses data is changed when curriculum is changed', async ({ page }) => {
await page.goto(pathToMathBSc2020)
await page.getByText('Courses of class').click()
const curriculumPicker = page.getByTestId('curriculum-picker')
await expect(curriculumPicker.getByText('2020–2023')).toBeVisible()
await expect(page.getByText('Students (27)')).toBeVisible()
await expect(page.getByTestId('toggle-group-module-DIGI-k')).toBeVisible()
await page.getByTestId('toggle-group-module-DIGI-k').click()
await expect(page.getByText('DIGI-100')).toBeVisible()
await expect(page.getByTestId('toggle-group-module-DIGI-k')).toBeVisible()
await curriculumPicker.click()
await page.getByText('2023–2026').click()
await expect(page.getByTestId('toggle-group-module-DIGI-k')).toBeVisible()
await expect(page.getByText('DIGI-100')).not.toBeVisible()
})
test('Courses data is changed when filtered students change', async ({ page }) => {
await page.goto(pathToMathBSc2020)
const filterCard = page.getByTestId('graduatedFromProgrammeFilter-filter-card')
await filterCard.getByTestId('graduatedFromProgrammeFilter-header').click()
await page.getByRole('radio', { name: 'Graduated', exact: true }).click()
await expect(page.getByRole('radio', { name: 'Graduated', exact: true })).toBeChecked()
await expect(page.getByText('Students (16)')).toBeVisible()
})
})
test.describe('Students', () => {
test.beforeEach(async ({ page }) => {
await page.goto(pathToMathBSc2020)
const studentsHeading = page.getByText('Students (27)')
const parentHasActive = await studentsHeading.evaluate(el => el.parentElement?.classList.contains('active'))
if (!parentHasActive) await page.getByText('Students (27)').click()
})
test("'General tab' is usable", async ({ page }) => {
const general = page.getByTestId('ooditable-general')
await expect(general).toBeVisible()
const row = general.locator('tbody').locator('tr').filter({ hasText: '489963' })
await expect(row.locator('td')).toHaveCount(24)
await expect(row.locator('td')).toHaveText(
[
'489963' + 'Sisu',
'Graduated',
241,
0,
169,
0,
0,
2020,
'2020-08-01',
'2020-08-01',
'2022-06-13',
23,
'',
'Matematiikka',
'Matematiikan ja tilastotieteen maisteriohjelma',
'Matematiikan ja tilastotieteen maisteriohjelma',
'',
'Avoin väylä',
'Male',
'Suomi',
'',
'',
'',
'',
].map(n => n.toString())
)
})
test("'Courses tab' is usable", async ({ page }) => {
await page.getByTestId('student-table-tabs').getByText('Courses').click()
const courses = page.getByTestId('ooditable-courses')
await expect(courses.getByText('MAT11001')).toBeVisible()
await expect(courses.getByText('MAT11004')).toBeVisible()
})
test("'Modules tab' Displays correct modules based on the selected programme", async ({ page }) => {
await page.getByTestId('student-table-tabs').getByText('Modules').click()
const modules = page.getByTestId('ooditable-modules')
await expect(modules.getByText('MAT011')).toBeVisible()
await expect(modules.getByText('MAT110')).toBeVisible()
await page.getByText('Courses of class').click()
await page.getByTestId('curriculum-picker').click()
await page.getByText('2023–2026').click()
await page.getByText('Courses of class').click()
await expect(page.getByTestId('ooditable-modules').getByText('MAT011')).not.toBeVisible()
await expect(page.getByTestId('ooditable-modules').getByText('MAT110')).toBeVisible()
})
test("Empty 'tags' tab has a link to the page where tags can be created", async ({ page }) => {
await page.getByTestId('student-table-tabs').getByText('Tags').click()
await expect(page.getByText('No tags defined. You can define them')).toBeVisible()
await page.getByRole('link', { name: 'here' }).click()
await expect(page).toHaveURL(/.*study-programme\/KH50_001\?tab=4.*/)
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
await expect(page.getByText('Create new tag')).toBeVisible()
await expect(page.getByTestId('create-button')).toBeDisabled()
})
})
})
test.describe('When using admin', () => {
test('Student list checking works as intended', async ({ page }) => {
await init(page, pathToMathBSc2020, 'admin')
const existing = '433237'
const nonExisting = '550004'
await expect(page.getByText('Students (27)')).toBeVisible()
await page.getByText('Students (27)').click()
await expect(page.getByText(existing)).toBeVisible()
await expect(page.getByText(nonExisting)).not.toBeVisible()
await page.getByRole('button', { name: 'Check student numbers' }).click()
await expect(page.getByText('Check for student numbers')).toBeVisible()
await page.getByTestId('check-student-numbers').getByRole('textbox').fill(`${existing}\n${nonExisting}`)
await page.getByRole('button', { name: 'Check students' }).click()
const results = page.locator('#checkstudentsresults')
await results.getByTestId('found-title').click()
await expect(results.getByTestId('found-data').getByText(existing)).toBeVisible()
await results.getByTestId('not-found-title').click()
await expect(results.getByTestId('not-found-data').getByText(nonExisting)).toBeVisible()
await results.getByTestId('not-searched-title').click()
await expect(results.getByTestId('not-searched-data').getByText('457144')).toBeVisible()
await page.getByRole('button', { name: 'Close' }).click()
await expect(page.getByText('Student numbers in list and in Sisu')).not.toBeVisible()
})
})
test.describe('When using IAM user', () => {
test.beforeEach(async ({ page }) => {
await init(page, pathToMathBSc2020, 'onlyiamrights')
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
})
test('Population statistics is visible', async ({ page }) => {
const card = page.getByTestId('PopulationQueryCard')
await expect(card.getByText('Excludes exchange students')).toBeVisible()
await expect(card.getByText('Excludes students with non-degree study right')).toBeVisible()
await expect(card.getByText('Excludes students who have transferred out of this programme')).toBeVisible()
})
test('Only correct panels are visible', async ({ page }) => {
const panelParent = page.getByTestId('panelview-parent')
await expect(panelParent.getByText('Credit accumulation (for 27 students)')).toBeVisible()
await expect(panelParent.getByText('Credit statistics')).toBeVisible()
await expect(panelParent.getByText('Age distribution')).toBeVisible()
await expect(panelParent.getByText('Courses of class')).toBeVisible()
})
test('Ages cannot be ungrouped', async ({ page }) => {
await expect(page.getByText('Age distribution')).toBeVisible()
await page.getByText('Age distribution').click()
await expect(page.getByText('Group ages')).not.toBeVisible()
})
test('Age filter is not visible', async ({ page }) => {
await expect(page.locator("[data-cy='filtered-students']")).toBeVisible()
await expect(page.locator("[data-cy='Age-filter-card']")).not.toBeVisible()
})
test('Students tab is not available', async ({ page }) => {
await expect(page.getByText('Students (27)')).not.toBeVisible()
})
})
})
+126
View File
@@ -0,0 +1,126 @@
import { expect, test } from '@playwright/test'
import { userHeaders } from './support/commands'
type RouteRequirements = {
userId?: string
roles?: string[]
requireProgrammeRights?: boolean
programmeRights?: string[]
iamGroups?: string[]
}
type UserInfo = {
userId?: string
roles?: string[]
programmeRights?: unknown[]
iamGroups?: string[]
}
const baseRoutes: [string, RouteRequirements][] = [
['/', {}],
['/changelog', {}],
['/completedcoursessearch', {}],
['/university', {}],
['/feedback', {}],
['/populations', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/study-programme', { roles: ['fullSisuAccess'], programmeRights: [] }],
// TODO: This should probably be allowed for user with programmeRight, not only fullSisuAccess
['/study-programme/KH50_001', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/coursepopulation', { roles: ['fullSisuAccess'], programmeRights: [] }],
['/users', { roles: ['admin'] }],
['/users/334', { roles: ['admin'] }],
['/updater', { roles: ['admin'] }],
['/coursestatistics', { roles: ['fullSisuAccess', 'courseStatistics'], programmeRights: [] }],
['/faculties', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
['/faculties/hy-org-1000000911', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
['/students', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/students/545400', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/custompopulation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
['/teachers', { roles: ['teachers'] }],
['/teachers/hy-hlo-1463447', { roles: ['teachers'] }],
['/studyguidancegroups', { roles: ['studyGuidanceGroups'] }],
['/studyguidancegroups/otm-b3b52ff5-88f6-4d52-bd18-0588023b6322', { userId: '', roles: ['studyGuidanceGroups'] }],
['/close-to-graduation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'] }],
['/languagecenterview', { iamGroups: ['grp-kielikeskus-esihenkilot'] }],
]
for (const user of userHeaders) {
if (user.uid === 'admin') continue
test.describe(`${user.displayname} has access only to views that were intended`, () => {
let userInfo: UserInfo | undefined
test.beforeAll(async ({ request }) => {
const response = await request.get('/api/login', { headers: user })
userInfo = (await response.json())?.user
})
test.beforeEach(async ({ page }) => {
expect(userInfo).toBeDefined()
// Inject user headers into all API calls and fail if any of them results in a server error
await page.route('**/api/**', async route => {
const response = await route.fetch({
headers: {
...route.request().headers(),
...user,
},
})
expect(response.status(), `${route.request().method()} ${route.request().url()}`).toBeLessThan(500)
await route.fulfill({ response })
})
})
test.afterEach(async ({ page }) => {
await page.unrouteAll({ behavior: 'ignoreErrors' })
})
for (const [route, requirements] of baseRoutes) {
test(`Checking access ${route}`, async ({ page }) => {
// TODO: JAMI mock needed to make this test deterministic
test.skip(user.uid === 'onlyiamrights' && route === '/coursestatistics')
await page.goto(route)
const { userId, roles = [], programmeRights = [], iamGroups = [] } = userInfo ?? {}
const hasProgrammeRights = programmeRights.length > 0
const requiredUserId = !requirements.userId || requirements.userId === userId
const requiredRoles = !requirements.roles || requirements.roles.some(role => roles.includes(role))
const requiredIamGroups =
!requirements.iamGroups || requirements.iamGroups.some(group => iamGroups.includes(group))
let requiredRights = !requirements.requireProgrammeRights || hasProgrammeRights
if (requirements.programmeRights) {
requiredRights =
requirements.programmeRights.length === 0
? hasProgrammeRights
: roles.includes('fullSisuAccess') ||
requirements.programmeRights.every(right => programmeRights.includes(right))
}
// This is defined in frontend ProtectedRoute component.
const specialNeedsRoutes = [
'populations',
'study-programme',
'coursepopulation',
'coursestatistics',
'students',
'custompopulation',
]
const mankeli = specialNeedsRoutes.some(path => route.includes(path))
? requiredRoles || requiredRights
: requiredRoles && requiredRights
const shouldHaveAccess = mankeli && requiredUserId && requiredIamGroups
if (shouldHaveAccess) {
await expect(page.getByRole('heading', { name: 'Something broke' })).not.toBeVisible()
await expect(page.getByRole('heading', { name: 'Access denied' })).not.toBeVisible()
} else {
await expect(page.getByRole('heading', { name: 'Access denied' })).toBeVisible()
}
})
}
})
}
+253
View File
@@ -0,0 +1,253 @@
import { expect, test, type Page } from '@playwright/test'
import { init } from './support/commands'
const student = {
firstNames: 'Varpu Roope',
lastName: 'Mårtensson',
studentNumber: '550003',
sisPersonId: 'hy-hlo-115926826',
email: 'sisutestidata134902@testisisudata.fi',
}
const searchFor = async (page: Page, value: string) => {
await page.getByTestId('student-search').getByRole('textbox').fill(value)
}
const openStudent = async (page: Page, studentNumber: string) => {
await searchFor(page, studentNumber)
await page.getByRole('cell', { name: studentNumber }).click()
}
const expectNamesHidden = async (page: Page) => {
await expect(page.getByText(student.firstNames)).toHaveCount(0)
await expect(page.getByText(student.lastName)).toHaveCount(0)
}
test.describe('Students tests', () => {
test.describe('When using basic user', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/students')
await expect(page).toHaveURL(/\/students/)
await expect(page.getByText('Show student names')).toBeVisible()
})
test('Students search form is usable', async ({ page }) => {
await searchFor(page, student.lastName)
await expect(page.getByText('Student number')).toBeVisible()
await expect(page.getByText('Last name')).toHaveCount(0)
await expect(page.getByText('First names')).toHaveCount(0)
await expect(page.getByText('Started')).toBeVisible()
await expect(page.getByText('Credits')).toBeVisible()
await expect(page.getByText('Active study rights')).toBeVisible()
await expectNamesHidden(page)
await page.getByTestId('toggleStudentNames').click()
await expect(page.getByText(student.firstNames)).toBeVisible()
await expect(page.getByText('Last name')).toBeVisible()
await expect(page.getByText('First names')).toBeVisible()
await expect(page.getByText(student.lastName)).toBeVisible()
await page.getByTestId('toggleStudentNames').click()
await expectNamesHidden(page)
})
test('Search term must be at least 4 characters long', async ({ page }) => {
await searchFor(page, student.lastName.slice(0, 3))
await expect(page.getByText('Search term is not accurate enough')).toBeVisible()
await page.getByTestId('student-search').getByRole('textbox').pressSequentially(student.lastName.slice(3)) // Appends to input
await expect(page.locator('table tbody tr')).toHaveCount(1)
})
test('Can search with student number too', async ({ page }) => {
await searchFor(page, student.studentNumber)
await expect(page.getByText(student.studentNumber)).toBeVisible()
})
test('Can get student specific page by clicking student', async ({ page }) => {
await openStudent(page, student.studentNumber)
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma (01.08.2020–31.07.2027)')).toBeVisible()
await expectNamesHidden(page)
await expect(page.getByText(student.email, { exact: true })).toHaveCount(0)
await page.getByTestId('toggleStudentNames').click()
await expect(page.getByText(student.lastName)).toBeVisible()
await expect(page.getByText(student.firstNames)).toBeVisible()
await expect(page.getByText(student.email)).toBeVisible()
await page.getByTestId('toggleStudentNames').click()
await expectNamesHidden(page)
await expect(page.getByText(student.email)).toHaveCount(0)
})
test("'Update student' button is not shown", async ({ page }) => {
await openStudent(page, student.studentNumber)
await expect(page.getByTestId('student-info-card').getByRole('button', { name: 'Update student' })).toHaveCount(0)
})
test('Can get back to search menu', async ({ page }) => {
await openStudent(page, student.studentNumber)
await page.goBack()
await expect(page.getByText('Student number')).toHaveCount(0)
await expect(page.getByText('Credits')).toHaveCount(0)
})
test('Can jump to course', async ({ page }) => {
await openStudent(page, student.studentNumber)
const courseRow = page.locator('tr').filter({ hasText: 'Tilastollinen päättely I (MAT12004)' })
await courseRow.locator('a').click()
await expect(page).toHaveURL(/\/coursestatistics/)
await expect(page.getByText('MAT12004', { exact: true })).toBeVisible()
await expect(page.getByText('AYMAT12004')).toBeVisible()
await expect(page.getByText('57046', { exact: true })).toBeVisible()
})
test('Has correct Sisu link', async ({ page }) => {
await openStudent(page, student.studentNumber)
await expect(page.getByTestId('sisu-link')).toHaveAttribute(
'href',
`https://sisu.helsinki.fi/tutor/role/staff/student/${student.sisPersonId}/basic/basic-info`
)
})
test('Semester enrollments can be toggled', async ({ page }) => {
await openStudent(page, student.studentNumber)
const programmes = [
'Oikeustieteen tohtoriohjelma',
'Matemaattisten tieteiden kandiohjelma',
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
]
const card = page.getByTestId('student-info-card')
await card.getByText('Enrollments').click()
await expect(card.locator('table tbody tr')).toHaveCount(3)
await expect(card.locator('table tbody tr td:first-child')).toHaveText(programmes)
})
test("Searching with bad name doesn't yield results", async ({ page }) => {
await searchFor(page, 'SWAG LITTINEN')
await expect(page.getByText('Student number')).toHaveCount(0)
})
test("Searching with bad student number doesn't yield results", async ({ page }) => {
await searchFor(page, '01114')
await expect(page.getByText('Student number')).toHaveCount(0)
})
test('Can jump to population page', async ({ page }) => {
await openStudent(page, student.studentNumber)
await page
.getByTestId('study-rights-section')
.getByText('Matemaattisten tieteiden kandiohjelma')
.getByRole('link')
.click()
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
})
test('Grade graph works in all three different modes', async ({ page }) => {
await openStudent(page, student.studentNumber)
await page.getByRole('tab', { name: 'Grade graph' }).click()
await page.getByRole('button', { name: 'Show group mean' }).click()
const groupSize = page.getByTestId('group-size-input')
await expect(groupSize.getByLabel('Group size')).toBeVisible()
await expect(groupSize.locator('input')).toHaveValue('5')
await groupSize.locator('input').fill('10')
await expect(groupSize.locator('input')).toHaveValue('10')
await page.getByRole('button', { name: 'Show semester mean' }).click()
await expect(groupSize.getByLabel('Group size')).not.toBeVisible()
})
test.describe('Bachelor Honours section', () => {
test('Shows qualified student details', async ({ page }) => {
await page.goto('/students/495976')
await expect(page.getByRole('heading', { name: 'Bachelor Honours' })).toBeVisible()
await expect(page.getByTestId('honours-chip-qualified')).toContainText('Qualified for Honours')
await page.getByText('Study modules').click()
const rows = page.getByTestId('main-modules').locator('tbody tr')
await expect(rows).toHaveCount(3)
await expect(
rows.filter({ hasText: 'Matemaattisten tieteiden kandiohjelma (KH50_001)' }).locator('td')
).toHaveText(['07.05.2020', 'Matemaattisten tieteiden kandiohjelma (KH50_001)', 'Hyv.'])
await expect(rows.filter({ hasText: 'Matematiikka, perusopinnot (MAT110)' }).locator('td')).toHaveText([
'08.05.2018',
'Matematiikka, perusopinnot (MAT110)',
'4',
])
await expect(rows.filter({ hasText: 'Matematiikka, aineopinnot (MAT210)' }).locator('td')).toHaveText([
'25.02.2020',
'Matematiikka, aineopinnot (MAT210)',
'4',
])
})
test('Shows Did not graduate in time', async ({ page }) => {
await page.goto('/students/540355')
await expect(page.getByTestId('honours-chip-not-qualified')).toHaveText('Not qualified for Honours')
await expect(page.getByTestId('honours-chip-error')).toHaveText('Did not graduate in time')
})
test('Shows Module grades too low', async ({ page }) => {
await page.goto('/students/547934')
await expect(page.getByTestId('honours-chip-not-qualified')).toHaveText('Not qualified for Honours')
await expect(page.getByTestId('honours-chip-error')).toHaveText('Module grades too low')
})
test('Shows Might need further inspection', async ({ page }) => {
await page.goto('/students/478837')
await expect(page.getByTestId('honours-chip-inspection')).toHaveText('Might need further inspection')
})
test('Shows Has not graduated', async ({ page }) => {
await page.goto(`/students/${student.studentNumber}`)
await expect(page.getByTestId('honours-chip-not-qualified')).toHaveText('Not qualified for Honours')
await expect(page.getByTestId('honours-chip-error')).toHaveText('Has not graduated')
})
})
})
test.describe('When using admin user', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/students', 'admin')
})
test('Does not crash if student has no study rights or courses', async ({ page }) => {
await openStudent(page, '450730')
await expect(page.getByText('Credits: 0')).toBeVisible()
})
test("'Update student' button is shown", async ({ page }) => {
await openStudent(page, student.studentNumber)
await expect(page.getByTestId('student-info-card').getByRole('button', { name: 'Update student' })).toBeVisible()
})
test('Bachelor Honours section is not shown outside Faculty of Science', async ({ page }) => {
await page.goto('/students/453146')
await expect(page.getByRole('heading', { name: 'Bachelor Honours' })).toHaveCount(0)
})
test('Study plan courses are highlighted with a blue background', async ({ page }) => {
await page.goto('/students/550789')
const studyPlan = page.getByTestId('KH40_005-0')
await expect(studyPlan).toContainText('Kulttuurien tutkimuksen kandiohjelma')
await studyPlan.locator('td').filter({ hasText: 'Kulttuurien tutkimuksen kandiohjelma' }).click()
await expect(page.locator('table tbody tr').filter({ hasText: 'Kandidaatintutkielma (KUKA-LIS222)' })).toHaveCSS(
'background-color',
'rgb(232, 244, 255)'
)
})
test('Completed credits are displayed for a matching study plan', async ({ page }) => {
await page.goto('/students/550789')
const studyPlan = page.getByTestId('KH40_005-0')
await expect(studyPlan).toContainText('Kulttuurien tutkimuksen kandiohjelma')
await expect(studyPlan).toContainText('185 cr')
})
test('Completion percentage is displayed for an unfinished matching study plan', async ({ page }) => {
await page.goto('/students/458723')
const studyPlan = page.getByTestId('KH20_001-0')
await expect(studyPlan).toContainText('Oikeusnotaarin koulutusohjelma')
await expect(studyPlan).toContainText('69% (125 cr)')
})
})
})
+193
View File
@@ -0,0 +1,193 @@
import { expect, Page, test } from '@playwright/test'
import { init, type UserId } from './support/commands'
const studyGuidanceGroups = [
{
id: 'sgg-cypress-1',
name: { fi: 'MAT kandit 2020' },
members: [{ personStudentNumber: '433237' }, { personStudentNumber: '457144' }, { personStudentNumber: '458090' }],
tags: { studyProgramme: 'KH50_001', year: '2020' },
},
{
id: 'sgg-cypress-2',
name: { fi: 'Oma ohjausryhmä' },
members: [{ personStudentNumber: '474270' }, { personStudentNumber: '479440' }],
tags: { studyProgramme: null, year: null },
},
]
const initWithGroups = async (page: Page, path: string, userId: UserId = 'onlystudyguidancegroups') => {
await init(page, path, userId)
await page.route('**/api/studyguidancegroups', async route => await route.fulfill({ json: studyGuidanceGroups }))
await page.reload()
}
test.describe('Study guidance group tests', () => {
test.describe('Study guidance group overview page', () => {
test('without study guidance groups shows the correct notification', async ({ page }) => {
await init(page, '/studyguidancegroups', 'admin')
await expect(page.getByRole('heading', { name: 'Study guidance groups' })).toBeVisible()
await expect(page.getByText('You do not have access to any study guidance groups.')).toBeVisible()
})
test('without correct rights shows the correct notification', async ({ page }) => {
await init(page, '/studyguidancegroups', 'basic')
await expect(page.getByRole('heading', { name: 'Access denied' })).toBeVisible()
await expect(
page.getByText(
"You don't currently have permission to view this page. If you believe this is a mistake, please contact oodikone@helsinki.fi."
)
).toBeVisible()
})
test.describe('with study guidance groups', () => {
test.beforeEach(async ({ page }) => {
await initWithGroups(page, '/studyguidancegroups')
await expect(page.getByRole('heading', { name: 'Study guidance groups' })).toBeVisible()
await expect(page.getByText('You do not have access to any study guidance groups')).not.toBeVisible()
})
test('shows the correct data', async ({ page }) => {
const row = page.getByTestId('study-guidance-group-overview-data-table').locator('tr')
// NOTE: Only MAT kandit 2020 can be edited
await expect(row.filter({ hasText: 'MAT kandit 2020' }).locator('td')).toHaveText([
'MAT kandit 2020',
'3',
'Matemaattisten tieteiden kandiohjelma' + 'Edit',
'2020 - 2021' + 'Edit',
])
await expect(row.filter({ hasText: 'Oma ohjausryhmä' }).locator('td')).toHaveText([
'Oma ohjausryhmä',
'2',
'Add degree programme',
'Add starting year',
])
})
test('has working links to single study guidance groups', async ({ page }) => {
await page.getByTestId('study-guidance-group-link-sgg-cypress-1').click()
await expect(page).toHaveURL('/studyguidancegroups/sgg-cypress-1')
})
})
})
test.describe('Page for single study guidance group', () => {
test.describe('without associated degree programme and year', () => {
test.beforeEach(async ({ page }) => {
await initWithGroups(page, '/studyguidancegroups/sgg-cypress-2')
})
test('shows the correct panes', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Oma ohjausryhmä' })).toBeVisible()
await expect(page.getByTestId('Credit accumulation (for 2 students)')).toBeVisible()
await expect(page.getByTestId('Age distribution')).toBeVisible()
await expect(page.getByTestId('Courses of population')).toBeVisible()
await expect(page.getByText('Students (2)')).toBeVisible()
await expect(page.getByTestId('Credit statistics')).not.toBeVisible()
})
test('students table has the correct tabs', async ({ page }) => {
await page.getByText('Students (2)').click()
await expect(page.getByTestId('student-table-tabs').getByRole('tab', { name: 'General' })).toBeVisible()
})
test('general tab of the students table has the correct columns', async ({ page }) => {
const columns = [
'Student number',
'Credits',
'Start date',
'Degree programmes',
'TVEX',
'Tags',
'Total',
'Since 1.1.1970',
'University',
]
await page.getByText('Students (2)').click()
await expect(page.getByTestId('ooditable-general').locator('table thead th')).toHaveText(columns)
})
})
test.describe('with associated degree programme and year', () => {
test.beforeEach(async ({ page }) => {
await initWithGroups(page, '/studyguidancegroups/sgg-cypress-1')
})
test('shows the correct labels', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'MAT kandit 2020' })).toBeVisible()
await expect(page.getByLabel('Associated degree programme')).toHaveText('Matemaattisten tieteiden kandiohjelma')
await expect(page.getByLabel('Associated starting academic')).toHaveText('2020 - 2021')
})
test('shows the correct panes', async ({ page }) => {
await expect(page.getByTestId('Credit accumulation (for 3 students)')).toBeVisible()
await expect(page.getByTestId('Credit statistics')).toBeVisible()
await expect(page.getByTestId('Age distribution')).toBeVisible()
await expect(page.getByTestId('Courses of population')).toBeVisible()
await expect(page.getByText('Students (3)')).toBeVisible()
})
test("clicking the 'Show credits starting from the associated academic year' filter", async ({ page }) => {
const filter = page.getByTestId('ParticipationDate-filter-card')
await expect(filter).toHaveAttribute('data-active', 'false')
await page.getByTestId('Credit accumulation (for 3 students)').click()
await page.getByText('Show credits starting from the associated academic year').click()
await expect(filter).toHaveAttribute('data-active', 'true')
await expect(page.locator('.date-picker').first().locator('input')).toHaveValue('01.08.2020')
await expect(page.getByText('Reset All Filters')).toBeVisible()
await page.getByText('Show credits starting from the associated academic year').click()
await expect(filter).toHaveAttribute('data-active', 'false')
})
test('students table has the correct tabs', async ({ page }) => {
await page.getByText('Students (3)').click()
await expect(page.getByTestId('student-table-tabs').getByRole('tab')).toHaveText([
'General',
'Courses',
'Modules',
'Progress',
])
})
test('general tab of the students table has the correct columns', async ({ page }) => {
const columns = [
'Student number',
'Status',
'Credits',
'Prior to bachelor',
'Start date',
'Graduation date',
'Semesters present',
'Study track',
'Other programmes',
'Transferred From',
'Admission Type',
'Gender',
'Citizenships',
'Curriculum period',
'Latest attainment date',
'TVEX',
'Tags',
'Total',
'In study plan',
'Since start in programme',
'Credits',
'Courses',
'University',
'Study right',
'Programme',
]
await page.getByText('Students (3)').click()
await expect(page.getByTestId('ooditable-general').locator('table thead th')).toHaveText(columns)
})
})
test('has a working back button', async ({ page }) => {
await initWithGroups(page, '/studyguidancegroups/sgg-cypress-1')
await page.getByRole('button', { name: 'Back to groups' }).click()
await expect(page).toHaveURL('/studyguidancegroups')
})
})
})
+85
View File
@@ -0,0 +1,85 @@
import { test, expect } from '@playwright/test'
import { init } from './support/commands'
test.describe('Teachers page tests', () => {
test.beforeEach(async ({ page }) => {
// login as admin = has teacher rights
await init(page, '/teachers', 'admin')
expect(page.url()).toContain('/teachers')
})
const teacher1 = 'Luokkanen Liisa Viljami'
const teacher2 = 'Perälä Juhani Susanna'
test('Check Statistics', async ({ page }) => {
await page.getByTestId('semester-start').click()
await page.getByText('Syksy 2020').click()
await page.getByTestId('course-providers').click()
await page.getByText('Matemaattisten tieteiden kandiohjelma').click()
await page.keyboard.press('Escape')
await page.getByTestId('search-statistics').click()
await expect(page.getByRole('columnheader')).toHaveCount(4)
await expect(page.getByText('Export to Excel')).toBeVisible()
const teacher1Row = page.locator('tr').filter({ hasText: teacher1 })
await expect(teacher1Row.getByText(teacher1)).toBeVisible()
await expect(teacher1Row.getByText('235')).toBeVisible()
await expect(teacher1Row.getByText('97.40%')).toBeVisible()
const teacher2Row = page.locator('tr').filter({ hasText: teacher2 })
await expect(teacher2Row.getByText(teacher2)).toBeVisible()
await expect(teacher2Row.getByText('395')).toBeVisible()
await expect(teacher2Row.getByText('98.78%')).toBeVisible()
})
test('Teacher search works', async ({ page }) => {
await page.getByTestId('Search').click()
await page
.getByTestId('teacher-search')
.getByPlaceholder('Search by entering a name or an id')
.fill(teacher1.split(' ')[0])
await expect(page.getByText(teacher1)).toBeVisible()
await expect(page.locator('tbody').locator('tr')).toHaveCount(4)
})
test('Can check teacher page', async ({ page, context }) => {
// Re-init with context so the tab opened from the teacher link gets mocked headers too.
await init(page, '/teachers', 'admin', context)
await page.getByTestId('Search').click()
await page.getByTestId('teacher-search').getByPlaceholder('Search by entering a name or an id').fill(teacher2)
await expect(page.getByText(teacher2)).toBeVisible()
expect(await page.getByText(teacher2).getAttribute('href')).toBe('/teachers/hy-hlo-49026530')
// Change to other tab
const pagePromise = context.waitForEvent('page')
await page.getByRole('link', { name: teacher2 }).click()
const newPage = await pagePromise
await newPage.waitForURL('**/teachers/hy-hlo-49026530')
await expect(newPage.getByRole('heading', { name: teacher2 })).toBeVisible()
await newPage.getByText('Syksy 2023').click()
await newPage.getByText('Kevät 2019').click()
await expect(newPage.getByText('MAT12004')).toBeVisible()
await expect(newPage.getByText('MAT22002')).toBeVisible()
await expect(newPage.getByText('MAT22003')).toBeVisible()
const courseRow = newPage.locator('tr').filter({ hasText: 'MAT12004' })
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
await expect(courseRow.locator('td')).toHaveText(rowContent)
})
test('Check leaderboad works', async ({ page }) => {
await page.getByTestId('Leaderboard').click()
await page.getByTestId('academic-year').click()
await page.getByText('2020-2021').click()
await expect(page.getByRole('columnheader')).toHaveCount(4)
await expect(page.getByText('Export to Excel')).toBeVisible()
await expect(page.getByText(teacher1)).toBeVisible()
})
})
+90
View File
@@ -0,0 +1,90 @@
import { expect, test } from '@playwright/test'
import { init } from './support/commands'
const progressLevels = ['bachelors', 'bachelor-masters', 'masters', 'doctoral']
const graduationTimesLevels = ['bachelor', 'bcMsCombo', 'master', 'doctor']
const checkProgressBarCharts = async (page: Parameters<typeof init>[0]) => {
for (const level of progressLevels) {
await expect(page.getByTestId(`faculty-${level}-progress-bar-chart-section`)).toBeVisible({ timeout: 60_000 })
}
}
const checkProgressTables = async (page: Parameters<typeof init>[0]) => {
for (const level of progressLevels) {
await expect(page.getByTestId(`${level}-faculty-progress-table`)).toBeVisible()
}
}
const checkGraduationCharts = async (page: Parameters<typeof init>[0], mode: string) => {
for (const level of graduationTimesLevels) {
const chart = page.getByTestId(`${level}-${mode}-bar-chart`)
await expect(chart).toBeVisible()
await expect(chart.getByText('Loading content')).not.toBeVisible()
}
}
test.describe('University view', () => {
test.slow()
test.beforeEach(async ({ page }) => {
await init(page, '/university')
await expect(page.getByRole('heading', { name: 'University', exact: true })).toBeVisible()
})
test.describe('Faculty progress tab', () => {
test('contains all the correct progress bar charts', async ({ page }) => {
await checkProgressBarCharts(page)
})
test('contains all the correct progress tables', async ({ page }) => {
await checkProgressTables(page)
})
test("'All study rights / Special study rights excluded' toggle works", async ({ page }) => {
await page.getByTestId('study-right-toggle').click()
await checkProgressBarCharts(page)
await checkProgressTables(page)
})
test('info boxes contain correct information', async ({ page }) => {
await page.getByTestId('faculty-progress-info-box-button').click()
await expect(page.getByTestId('faculty-progress-info-box-content')).toContainText('Kuvaa tiedekuntaan kuuluvien')
await page.getByTestId('faculty-bachelor-masters-progress-info-box-button').click()
await expect(page.getByTestId('faculty-bachelor-masters-progress-info-box-content')).toContainText(
'The starting year is the'
)
})
})
test.describe('Faculty graduations tab', () => {
test.beforeEach(async ({ page }) => {
await page.getByTestId('faculty-graduations-tab').click()
})
test.describe('Different modes work', () => {
test('Breakdown', async ({ page }) => {
await page.getByTestId('graduation-mode-selector').getByTestId('select-breakdown').click()
await checkGraduationCharts(page, 'breakdown')
})
test('Median', async ({ page }) => {
await page.getByTestId('graduation-mode-selector').getByTestId('select-median').click()
await checkGraduationCharts(page, 'median')
})
test('Average', async ({ page }) => {
await page.getByTestId('graduation-mode-selector').getByTestId('select-average').click()
await checkGraduationCharts(page, 'average')
})
})
test('info boxes contain correct information', async ({ page }) => {
await page.getByTestId('average-graduation-times-info-box-button').hover()
await expect(page.getByTestId('average-graduation-times-info-box-content')).toContainText(
'Opiskelijoiden keskimääräiset valmistumisajat'
)
})
})
})
+100
View File
@@ -0,0 +1,100 @@
import { test, expect, Page } from '@playwright/test'
import { init } from './support/commands'
const visibleLinks = {
norights: ['University', 'Faculties', 'Special populations', 'Feedback'],
onlycoursestatistics: ['University', 'Courses', 'Special populations', 'Feedback'],
basic: [] as string[],
admin: [] as string[],
}
visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Programmes', 'Students']
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
const containsLinks = async (page: Page, links: string[]) => {
const navBar = page.getByTestId('nav-bar')
await Promise.all(links.map(link => expect(navBar.getByText(link)).toBeVisible()))
}
const userButtonWorks = async (page: Page, username: string, mocking = false) => {
await page.getByTestId('nav-bar-user-button').click()
await expect(page.getByText(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)).toBeVisible()
await expect(page.getByText('Language')).toBeVisible()
await expect(page.getByText('suomi')).toBeVisible()
await expect(page.getByText('English')).toBeVisible()
await expect(page.getByText('svenska')).toBeVisible()
await expect(page.getByText(mocking ? 'Stop mocking' : 'Log out')).toBeVisible()
}
test.describe('Users tests', () => {
test.describe('Using as user with just grp-oodikone-user, no other rights', () => {
test('shows correct tabs', async ({ page }) => {
await init(page, '', 'norights')
await containsLinks(page, visibleLinks.norights)
await userButtonWorks(page, 'norights')
})
})
test.describe('Using as coursestatistics user', () => {
test('shows correct tabs', async ({ page }) => {
await init(page, '', 'onlycoursestatistics')
await containsLinks(page, visibleLinks.onlycoursestatistics)
await userButtonWorks(page, 'onlycoursestatistics')
})
})
test.describe('Using as basic user', () => {
test('shows correct tabs', async ({ page }) => {
await init(page, '', 'basic')
await containsLinks(page, visibleLinks.basic)
await userButtonWorks(page, 'basic')
})
})
test.describe('Using as admin', () => {
test.beforeEach(async ({ page }) => {
await init(page, '/users', 'admin')
})
test('should see more stuff than others', async ({ page }) => {
await containsLinks(page, visibleLinks.admin)
await userButtonWorks(page, 'admin')
})
test.describe('can mock as other users', () => {
test.beforeEach(async ({ page }) => {
await page.getByTestId('user-page-button-basic').click()
await expect(page.getByText('Basic User')).toBeVisible()
await expect(page.getByText('IAM Groups')).toBeVisible()
await expect(page.getByText('Roles')).toBeVisible()
await expect(page.getByText('Degree programme rights')).toBeVisible()
await page.getByTestId('mock-button').click()
})
test('user button shows mocked user', async ({ page }) => {
await userButtonWorks(page, 'basic', true)
})
test("only the mocked user's programmes are visible", async ({ page }) => {
await page.route('**/api/populationstatistics/studyprogrammes', async route => {
const response = await route.fetch()
expect(response.status()).toBeLessThanOrEqual(304)
await route.fulfill({ response })
})
await init(page, '/populations', 'basic')
await expect(page.getByRole('heading', { name: 'Degree programme' })).toBeVisible()
const populationProgrammeSelector = page.getByTestId('population-programme-selector')
await expect(populationProgrammeSelector.getByPlaceholder('Select degree programme')).toBeVisible()
const programmeSelectorParent = page.getByTestId('population-programme-selector-parent')
await programmeSelectorParent.click()
await expect(programmeSelectorParent).toContainText('Matematiikan ja tilastotieteen maisteriohjelma')
})
})
})
})
+109
View File
@@ -0,0 +1,109 @@
// ! IMPORTANT: here we need to set keys to be all lowercase, since
// ! we're replacing headers after they've left browser / frontend.
import { BrowserContext, expect, Page } from '@playwright/test'
// All of these users are available in anon user-db
const adminUserHeaders = {
uid: 'admin',
displayname: 'Admin User',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'grp-oodikone-users;grp-oodikone-basic-users;grp-toska',
mail: 'grp-toska+mockadminuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-6666666',
}
const basicUserHeaders = {
uid: 'basic',
displayname: 'Basic User',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'grp-oodikone-users;grp-oodikone-basic-users',
mail: 'grp-toska+mockbasicuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-1234567',
}
const onlycoursestatisticsUserHeaders = {
uid: 'onlycoursestatistics',
displayname: 'Onlycoursestatistics User',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'grp-oodikone-basic-users',
mail: 'grp-toska+mockonlycoursestatisticsuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-7654321',
}
const norightsUserHeaders = {
uid: 'norights',
displayname: 'Norights User',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'grp-oodikone-users',
mail: 'grp-toska+mocknorightuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-0000000',
}
const onlyIamRightsUserHeaders = {
uid: 'onlyiamrights',
displayname: 'Only IAM rights user',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'hy-employees;hy-mltdk-mat-jory',
mail: 'grp-toska+mockonlyiamrightsuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-1111111',
}
// Also has element detail of "KH50_005" for the study guidance groups to work
const onlyStudyGuidanceGroupsUser = {
uid: 'onlystudyguidancegroups',
displayname: 'Study Guidance Groups User',
'shib-session-id': 'mock-cypress-session',
hygroupcn: 'grp-oodikone-users',
mail: 'grp-toska+mockonlystudyguidancegroupsuser@helsinki.fi',
hypersonsisuid: 'hy-hlo-2222222',
}
export const userHeaders = [
adminUserHeaders,
basicUserHeaders,
onlycoursestatisticsUserHeaders,
norightsUserHeaders,
onlyIamRightsUserHeaders,
onlyStudyGuidanceGroupsUser,
]
export const testMockTime = '2026-03-06'
export type UserId =
| 'admin'
| 'basic'
| 'onlycoursestatistics'
| 'norights'
| 'onlyiamrights'
| 'onlystudyguidancegroups'
export const init = async (page: Page, path: string, userId: UserId = 'basic', context?: BrowserContext) => {
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
// Prefer context-level routing when provided so new tabs inherit header injection.
const routeTarget = context ?? page
await routeTarget.route('**/api/**', async route => {
await route.continue({
headers: {
...route.request().headers(),
...headersToUse,
'x-test-now': testMockTime,
},
})
})
await page.clock.install({ time: testMockTime })
await page.goto(path || '/')
}
export const checkTableStats = async (page: Page, correctStats: (string | number)[][], tableName: string) => {
const testId = tableName + '-data-table'
await expect(page.getByTestId(testId).getByText('Loading content')).not.toBeVisible()
const table = page.getByTestId(testId).locator('tbody')
await expect(table).toBeVisible()
await expect(table.locator('tr > td')).toHaveText(correctStats.flatMap(stats => stats.map(value => value.toString())))
}
Executable
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env bash
# Oodikone CLI tool (revision 2.0)
# Bash template provided by:
# https://betterdev.blog/minimal-safe-bash-script-template/
set -Eueo pipefail
trap cleanup SIGINT SIGTERM ERR EXIT
# Constants
# Resolve relative to the script's own location (not the caller's cwd), so
# `ok.sh` behaves the same whether it's run as `./ok.sh` from the repo or
# invoked with an absolute/symlinked path from anywhere (e.g. the `ok`
# shell function installed via `./ok.sh install`).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
cd "$PROJECT_ROOT"
# Include scripts from other sources
source "$PROJECT_ROOT"/scripts/functions_for_setup.sh
usage() {
cat << EOF # remove the space between << and EOF, this is due to web plugin issue
Usage: $(basename "${BASH_SOURCE[0]}") [command]
Run without a command to open the interactive menu.
Available commands:
up, u, r, real Start Oodikone with real data
rs, rl Restart Oodikone with real data (down, then up)
anon, a Start Oodikone with anonymous data
down, d Stop Oodikone
logs, l Follow oodikone service logs
logs:updater, lu Follow sis-updater service logs
lint, li Lint the project or the path given as parameter
format, fmt, f Format the project or the path given as parameter
test:backend, tb Run backend unit tests
test:frontend, tf Run frontend unit tests
test:updater, tu Run updater (worker) unit tests
test:services, ts Run backend service tests
test:api, ta Run backend api tests
test:component, tc Run frontend component tests tests
test:e2e, te Run all e2e tests (add --ui to run in headed mode)
importer, i Open a psql/pgcli shell to the importer database
updater Open a psql/pgcli shell to the updater (sis) database
install Add the 'ok' shell function to your shell rc file
-h, --help Print this help and exit
EOF
exit
}
cleanup() {
trap - SIGINT SIGTERM ERR EXIT
msg ""
errormsg "(╯°□°)╯︵ ┻━┻"
}
setup_colors() {
if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m'
else
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE=''
fi
}
die() {
local msg=$1
local code=${2-1} # default exit status 1
msg "$msg"
exit "$code"
}
get_pgclient() {
if command -v pgcli >/dev/null 2>&1; then
echo "pgcli"
else
warningmsg "Couldn't verify pgcli installation, using psql as fallback" >&2
echo "psql"
fi
}
show_welcome() {
local cashmoneyyellow
cashmoneyyellow=$(tput setaf 221)
local normal
normal=$(tput sgr0)
if [ "$(tput cols)" -gt "76" ]; then
while IFS="" read -r p || [ -n "$p" ]; do
printf '%40s\n' "${cashmoneyyellow}$p${normal}"
done < "$PROJECT_ROOT"/scripts/assets/logo.txt
fi
msg "Welcome to Oodikone CLI!"
msg ""
}
main_menu_opts() {
msg "1) Start Oodikone 2) Databases 3) Data refresh 4) Setup q) Quit\n"
}
database_menu_opts() {
msg "\nView local databases ($datasource data)"
msg "suffix: $dbsuffix"
msg "1) Oodikone"
msg "2) Importer"
msg "3) Kone"
msg "4) Toggle data source (test/real)"
msg "5) Go back"
msg ""
}
database_menu() {
# If a single mandatory service is down, an assumption is made that they all are.
if ! docker compose ps 2>/dev/null | grep -q 'oodikone-backend'; then
msg ""
warningmsg "Can't access databases, oodikone is not running!\nPlease run option 1 first with preferred data source."
return
fi
declare dbsuffix="-real"
declare datasource="real"
toggle_environment() {
if [[ $dbsuffix = "" ]];
then
dbsuffix="-real"
datasource="real"
else
dbsuffix=""
datasource="test"
fi
}
local pgclient
pgclient="$(get_pgclient)"
msg "${GREEN}\nUsing $pgclient for database connections${NOFORMAT}"
database_menu_opts $datasource
local userinput
while true; do
read -r -s -n 1 -p "Please select an option: " userinput
case ${userinput} in
1|o|O)
msg "Oodikone-db"
$pgclient -h localhost -p 12345 -U postgres -d "sis-db$dbsuffix" -w
database_menu_opts
;;
2|i|I)
msg "Importer-db"
$pgclient -h localhost -p 12346 -U postgres -d "sis-importer-db$dbsuffix" -w
database_menu_opts
;;
3|k|K)
msg "Kone-db"
$pgclient -h localhost -p 4321 -U postgres -d "kone-db$dbsuffix" -w
database_menu_opts
;;
4|t|T)
msg "Toggling environment..."
database_menu_opts
;;
5|q|Q)
msg ""
break
;;
*)
true
;;
esac
done
}
main_loop() {
local userinput
while true; do
main_menu_opts
read -r -s -n 1 -p "Please select an option: " userinput
case ${userinput} in
1)
msg "\nTODO: todo\n";;
2)
database_menu;;
3)
msg "\nTODO: todo\n";;
4)
msg "\nTODO: todo\n";;
5|q|Q)
break 2;;
*)
errormsg "${RED}Invalid option:${NOFORMAT} $userinput";;
esac
done
}
# === Direct (non-interactive) commands ===
run_up() {
infomsg "Starting Oodikone with real data..."
npm run both:real "$@"
}
run_restart() {
infomsg "Restarting Oodikone with real data..."
npm run docker:down
npm run both:real
}
run_anon() {
infomsg "Starting Oodikone with anonymous data..."
npm run both "$@"
}
run_down() {
infomsg "Stopping Oodikone..."
npm run docker:down "$@"
}
run_logs() {
npm run logs "$@"
}
run_lint() {
npm run lint -- "$@"
}
run_format() {
npm run fmt -- "$@"
}
run_unit_tests() {
npm run test:unit --prefix "$@"
}
run_component_tests() {
npm run test:component --prefix services/frontend "$@"
}
run_service_tests() {
npm run test:services --prefix services/backend "$@"
}
run_api_tests() {
npm run test:api --prefix services/backend "$@"
}
run_e2e_tests() {
npm run playwright -- "$@"
}
run_logs_updater() {
docker compose --file docker-compose.yml logs sis-updater-worker sis-updater-scheduler -f
}
run_importer_db() {
local pgclient
pgclient="$(get_pgclient)"
msg "Importer-db"
$pgclient -h localhost -p 12346 -U postgres -d sis-importer-db-real -w
}
run_updater_db() {
local pgclient
pgclient="$(get_pgclient)"
msg "Updater-db"
$pgclient -h localhost -p 12345 -U postgres -d sis-db-real -w
}
# Add (or refresh) an `ok` shell function in the user's shell rc file, which
# simply forwards all arguments to this script. This is the one-time setup
# step referenced above the shell function no longer needs its own copy of
# oodikone-specific logic.
cmd_install() {
local shell_name
shell_name="$(basename "${SHELL:-bash}")"
local shell_rc
case "$shell_name" in
zsh) shell_rc="$HOME/.zshrc" ;;
*) shell_rc="$HOME/.bashrc" ;;
esac
local ok_path="$PROJECT_ROOT/ok.sh"
local marker_start="# >>> oodikone ok.sh >>>"
local marker_end="# <<< oodikone ok.sh <<<"
if [ -f "$shell_rc" ] && grep -qF "$marker_start" "$shell_rc"; then
warningmsg "ok.sh integration already present in $shell_rc, skipping."
infomsg "To reinstall (e.g. after moving the repo), remove the block between\n'$marker_start' and '$marker_end' in $shell_rc and re-run 'ok.sh install'."
return 0
fi
{
echo ""
echo "$marker_start"
echo "ok() { \"$ok_path\" \"\$@\"; }"
echo "$marker_end"
} >> "$shell_rc"
successmsg "Added the 'ok' function to $shell_rc"
infomsg "Run 'source $shell_rc' or restart your shell, then try 'ok up'."
}
# Main
setup_colors
case "${1-}" in
up|u|r|real)
shift
run_up "$@"
;;
rs|rl)
shift
run_restart "$@"
;;
anon|a)
shift
run_anon "$@"
;;
down|d)
shift
run_down "$@"
;;
logs|l)
shift
run_logs "$@"
;;
lint|li)
shift
run_lint "$@"
;;
format|fmt|f)
shift
run_format "$@"
;;
test:backend|tb)
shift
run_unit_tests services/backend "$@"
;;
test:frontend|tf)
shift
run_unit_tests services/frontend "$@"
;;
test:updater|tu)
shift
run_unit_tests updater/sis-updater-worker "$@"
;;
test:services|ts)
shift
run_service_tests "$@"
;;
test:api|ta)
shift
run_api_tests "$@"
;;
test:component|tc)
shift
run_component_tests "$@"
;;
test:e2e|te)
shift
run_e2e_tests "$@"
;;
logs:updater|lu)
shift
run_logs_updater "$@"
;;
importer|i)
shift
run_importer_db
;;
updater)
shift
run_updater_db
;;
install)
shift
cmd_install
;;
-h|--help)
usage
;;
"")
show_welcome
get_s3_config
main_loop
;;
*)
errormsg "Unknown command: $1"
usage
;;
esac
+63 -1276
View File
File diff suppressed because it is too large Load Diff
+5 -10
View File
@@ -5,17 +5,17 @@
"node": ">=24.0.0"
},
"scripts": {
"cypress": "cypress",
"cypress:run": "cypress run --browser firefox",
"playwright": "playwright test -c playwright.config.ts",
"playwright:ui": "playwright test --ui -c playwright.config.ts",
"playwright:headed": "playwright test --headed -c playwright.config.ts",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"lint:quiet": "oxlint --quiet",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"stylelint": "stylelint",
"prettier": "prettier",
"cli": "./cli.sh",
"logs": "docker compose --file docker-compose.yml logs backend frontend sis-updater-worker sis-updater-scheduler jami -f",
"logs": "docker compose --file docker-compose.yml logs backend frontend sis-updater-worker sis-updater-scheduler -f",
"logs:all": "docker compose --file docker-compose.yml --profile oodikone --profile updater logs -f",
"logs:ci": "docker compose --file docker-compose.ci.yml logs backend frontend sis-updater-worker sis-updater-scheduler jami",
"oodikone": "npm run docker:down && ./run.sh oodikone anon up --detach",
@@ -43,13 +43,9 @@
"bugs": {
"url": "https://github.com/UniversityOfHelsinkiCS/oodikone/issues"
},
"allowScripts": {
"cypress": true
},
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"cypress": "15.18.1",
"eslint-plugin-cypress": "^6.2.3",
"@playwright/test": "1.61.0",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
@@ -59,7 +55,6 @@
"oxfmt": "0.58.0",
"oxlint": "1.73.0",
"oxlint-tsgolint": "0.25.0",
"prettier": "3.5.3",
"stylelint": "^17.6.0",
"stylelint-config-standard": "^40.0.0"
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test'
// oxlint-disable-next-line no-default-export
export default defineConfig({
testDir: './e2e',
testMatch: ['**/*.spec.ts'],
testIgnore: ['**/support/**', '**/plugins/**'],
// Fail the build on CI if you accidentally left test.only in the source code.
forbidOnly: !!process.env.CI,
timeout: 30_000,
expect: { timeout: 10_000 },
reporter: [['list', { printFailuresInline: true }]],
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 4 : undefined,
use: {
baseURL: process.env.CI ? 'http://localhost:1337' : process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
viewport: { width: 1920, height: 1080 },
trace: 'on-first-retry',
testIdAttribute: 'data-cy',
},
projects: [
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
})
+1
View File
@@ -114,3 +114,4 @@ cypress/
**/*Dockerfile
**/build
**/dist
**/.npm
+1 -1
View File
@@ -40,7 +40,7 @@ export const rootOrgId = process.env.ROOT_ORG_ID ?? 'hy-university-root-id'
export const concurrentWorkers = parseInt(process.env.CONCURRENT_WORKERS!, 10) || 2
export const DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS!, 10) || 5 // sequelize's default
export const DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS!, 10) || 25
export const DB_MAX_CRON_CONNECTIONS = Math.max(1, DB_MAX_CONNECTIONS - 5)
export const CRON_SCHEDULE = process.env.CRON_SCHEDULE ?? '0 23 * * *' // Default to 23:00 daily
+33 -4
View File
@@ -1,4 +1,4 @@
// Map programme codes to "letter indentifier"
// Map programme codes to "letter indentifiers" i.e. abbreviations
export const programmeCodes = {
KH10_001: 'TUK',
@@ -10,6 +10,7 @@ export const programmeCodes = {
KH30_001: 'PSYKK',
KH30_002: 'LOGOK',
KH30_003: 'SPK',
KH30_004: 'LKHLK',
MH30_001: 'LL',
MH30_002: 'TMED',
MH30_003: 'HLL',
@@ -22,6 +23,12 @@ export const programmeCodes = {
KH40_004: 'KOK',
KH40_005: 'KUKA',
KH40_006: 'HISK',
KH40_007: 'LAS',
KH50_009: 'ATM-BP-CE',
KH50_009_ATMCE: 'ATM-BP-CE',
KH50_010: 'BASUS',
KH80_005: 'EKK',
KH80_006: 'YMTA',
MH40_001: 'TTM',
MH40_002: 'KIM',
MH40_003: 'ENG',
@@ -44,6 +51,9 @@ export const programmeCodes = {
KH50_006: 'GEOK',
KH50_007: 'MAA',
KH50_008: 'BSC',
MH30_007: 'ORPM',
MH30_008: 'LL',
MH30_009: 'HLL',
MH50_001: 'MAST',
MH50_002: 'LSI',
MH50_003: 'TCM',
@@ -58,6 +68,8 @@ export const programmeCodes = {
MH50_012: 'GEOG',
MH50_013: 'USP',
MH50_014: 'EnCHiL',
MH50_015: 'ATM-MP-CE',
MH50_015_ATMCE: 'ATM-MP-CE',
KH55_001: 'FARM',
MH55_001: 'PROV',
MH55_002: 'MPHARM',
@@ -69,6 +81,7 @@ export const programmeCodes = {
MH57_003: 'GMB',
MH57_004: 'NEURO',
MH57_005: 'ECGS',
MH57_006: 'LYYRA',
KH60_001: 'EDUK',
MH60_001: 'EDUM',
MH60_002: 'CE',
@@ -90,16 +103,18 @@ export const programmeCodes = {
KH74_001: 'KSV',
KH80_001: 'MAATK',
KH80_002: 'METSAK',
KH80_003: 'ETK',
KH80_003: 'ERK',
KH80_004: 'YET',
MH80_001: 'AGRI',
MH80_002: 'AGERE',
MH80_003: 'FOR',
MH80_004: 'FOOD',
MH80_005: 'HNFB',
MH80_005: 'NUTR',
MH80_006: 'EKM',
MH80_007: 'MMB',
MH80_008: 'emPlant',
MH80_009: 'MGB',
MH80_011: 'STECH',
KH90_001: 'ELK',
MH90_001: 'ELL',
T920101: 'DPTHEOL',
@@ -130,9 +145,23 @@ export const programmeCodes = {
T922106: 'FoodHealth',
T923101: 'PAPU',
T923102: 'GeoDoc',
T923103: 'ATM-DP',
T923103: 'ATM-DP-CE',
T923104: 'CHEMS',
T923105: 'DOMAST',
T923106: 'MATRENA',
T923107: 'DOCS',
DP001: 'DPBM',
DP002: 'EEESY',
DP003: 'MaCS',
DP004: 'HUM-DP',
DP005: 'EDUPHD',
DP006: 'CLINDOC',
DP007: 'DPDR',
DP008: 'SciDoc',
DP009: 'LLD',
DP010: 'TRES',
DP011: 'RENEW',
DP012: 'PopBe',
DP013: 'HEAL',
DP014: 'SOC',
} as const
+14 -2
View File
@@ -53,10 +53,10 @@ class DbConnection extends EventEmitter {
this.sequelize = new Sequelize(conf.SIS_DB_URL!, {
dialect: 'postgres',
pool: {
max: 25,
max: conf.DB_MAX_CONNECTIONS,
min: 0,
acquire: 30000,
idle: 300000000,
idle: 30000,
},
logging: false,
password: conf.SIS_PASSWORD,
@@ -107,6 +107,12 @@ const sequelizeKone = new Sequelize(conf.DB_URL_KONE!, {
schema: conf.DB_SCHEMA_KONE,
logging: false,
password: conf.KONE_PASSWORD,
pool: {
max: conf.DB_MAX_CONNECTIONS,
min: 0,
acquire: 30000,
idle: 30000,
},
models: [
BannerModel,
CustomPopulationSearchModel,
@@ -125,6 +131,12 @@ void sequelizeKone.query(`SET SESSION search_path to ${conf.DB_SCHEMA_KONE}`)
const sequelizeUser = new Sequelize(conf.DB_URL_USER!, {
logging: false,
password: conf.USER_PASSWORD,
pool: {
max: conf.DB_MAX_CONNECTIONS,
min: 0,
acquire: 30000,
idle: 30000,
},
models: [UserModel],
})
@@ -0,0 +1,24 @@
const { STRING, JSONB } = require('sequelize')
module.exports = {
up: async ({ context: queryInterface }) => {
await queryInterface.addColumn('custom_population_searches', 'mode', {
type: STRING,
})
await queryInterface.addColumn('custom_population_searches', 'programmes', {
type: JSONB,
})
await queryInterface.addColumn('custom_population_searches', 'year', {
type: STRING,
})
await queryInterface.bulkUpdate('custom_population_searches', { mode: 'studentNumbers' }, {})
},
down: async ({ context: queryInterface }) => {
await queryInterface.bulkDelete('custom_population_searches', [{ mode: 'programmes' }])
await queryInterface.removeColumn('custom_population_searches', 'mode')
await queryInterface.removeColumn('custom_population_searches', 'programmes')
await queryInterface.removeColumn('custom_population_searches', 'year')
},
}
@@ -0,0 +1,14 @@
const { STRING } = require('sequelize')
// Migration step 1 of 2
module.exports = {
up: async ({ context: queryInterface }) => {
await queryInterface.addColumn('excluded_courses', 'course_group_id', {
type: STRING,
})
},
down: async ({ context: queryInterface }) => {
await queryInterface.removeColumn('excluded_courses', 'course_group_id')
},
}
@@ -0,0 +1,20 @@
const { ARRAY, STRING } = require('sequelize')
const YEARS = ['one', 'two', 'three', 'four', 'five', 'six']
// Migration step 1 of 2
module.exports = {
up: async ({ context: queryInterface }) => {
for (const year of YEARS) {
await queryInterface.addColumn('progress_criteria', `course_group_ids_year_${year}`, {
type: ARRAY(STRING),
})
}
},
down: async ({ context: queryInterface }) => {
for (const year of YEARS) {
await queryInterface.removeColumn('progress_criteria', `course_group_ids_year_${year}`)
}
},
}
@@ -0,0 +1,94 @@
/* eslint-disable no-console */
const { ARRAY, STRING } = require('sequelize')
const YEARS = ['one', 'two', 'three', 'four', 'five', 'six']
const UNIQUE_CONSTRAINT = 'excluded_courses_programme_version_group_key'
// Migration step 2 of 2
// Rows the backfill could not map to a course group id are removed, as they no longer reference a course.
// NOTE: down only restores the schema, the dropped course codes cannot be restored.
module.exports = {
up: async ({ context: queryInterface }) => {
await queryInterface.sequelize.transaction(async transaction => {
const [, unmapped] = await queryInterface.sequelize.query(
'DELETE FROM excluded_courses WHERE course_group_id IS NULL',
{ transaction }
)
console.log(`Deleted ${unmapped.rowCount} excluded courses without a course group id`)
const [, duplicates] = await queryInterface.sequelize.query(
`DELETE FROM excluded_courses a
USING excluded_courses b
WHERE a.id > b.id
AND a.programme_code = b.programme_code
AND a.curriculum_version IS NOT DISTINCT FROM b.curriculum_version
AND a.course_group_id = b.course_group_id`,
{ transaction }
)
console.log(`Deleted ${duplicates.rowCount} duplicate excluded courses`)
await queryInterface.removeColumn('excluded_courses', 'course_code', { transaction })
await queryInterface.changeColumn(
'excluded_courses',
'course_group_id',
{ type: STRING, allowNull: false },
{ transaction }
)
await queryInterface.addConstraint('excluded_courses', {
fields: ['programme_code', 'curriculum_version', 'course_group_id'],
type: 'unique',
name: UNIQUE_CONSTRAINT,
transaction,
})
for (const year of YEARS) {
const [[{ count }]] = await queryInterface.sequelize.query(
`SELECT COUNT(*) AS count FROM progress_criteria
WHERE course_group_ids_year_${year} IS NULL AND cardinality(courses_year_${year}) > 0`,
{ transaction }
)
console.log(`Emptied year ${year} of ${count} progress criteria without course group ids`)
await queryInterface.sequelize.query(
`UPDATE progress_criteria SET course_group_ids_year_${year} = '{}' WHERE course_group_ids_year_${year} IS NULL`,
{ transaction }
)
await queryInterface.removeColumn('progress_criteria', `courses_year_${year}`, { transaction })
await queryInterface.changeColumn(
'progress_criteria',
`course_group_ids_year_${year}`,
{ type: ARRAY(STRING), allowNull: false, defaultValue: [] },
{ transaction }
)
}
})
},
down: async ({ context: queryInterface }) => {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeConstraint('excluded_courses', UNIQUE_CONSTRAINT, { transaction })
await queryInterface.changeColumn(
'excluded_courses',
'course_group_id',
{ type: STRING, allowNull: true },
{ transaction }
)
await queryInterface.addColumn('excluded_courses', 'course_code', { type: STRING }, { transaction })
for (const year of YEARS) {
await queryInterface.changeColumn(
'progress_criteria',
`course_group_ids_year_${year}`,
{ type: ARRAY(STRING), allowNull: true, defaultValue: null },
{ transaction }
)
await queryInterface.addColumn(
'progress_criteria',
`courses_year_${year}`,
{ type: ARRAY(STRING) },
{ transaction }
)
}
})
},
}
+2 -1
View File
@@ -13,6 +13,7 @@ import { updateBasicView, updateStudyTrackView } from './services/studyProgramme
import { findAndSaveTeachers } from './services/teachers/top'
import { deleteOutdatedUsers } from './services/userService'
import logger from './util/logger'
import { now } from './util/clock'
import { jobQueue } from './worker/queue'
const schedule = (cronTime: string, onTick: () => void) => {
@@ -27,7 +28,7 @@ export const refreshCloseToGraduating = async () => {
const updatedData = await findStudentsCloseToGraduation()
await redisClient.set(
CLOSE_TO_GRADUATION_REDIS_KEY,
JSON.stringify({ ...updatedData, lastUpdated: new Date().toISOString() })
JSON.stringify({ ...updatedData, lastUpdated: now().toISOString() })
)
logger.info('Students close to graduating updated!')
}
@@ -0,0 +1,16 @@
import type { Request, Response, NextFunction } from 'express'
import { runningInCI } from '../config'
import { clockStorage } from '../util/clock'
import logger from '../util/logger'
export function testTimeMiddleware(req: Request, _res: Response, next: NextFunction) {
const testNow = req.header('x-test-now')
if (runningInCI && testNow) {
logger.info(`Setting test time: ${testNow}`)
return clockStorage.run({ now: new Date(testNow) }, next)
}
return clockStorage.run({}, next)
}
+30 -21
View File
@@ -21,15 +21,19 @@ import { EnrollmentModel } from './enrollment'
import { OrganizationModel } from './organization'
@Table({
underscored: false,
underscored: true,
modelName: 'course',
tableName: 'course',
})
export class CourseModel extends Model implements Course {
/* COLUMNS */
@PrimaryKey
@Column(DataType.STRING)
declare id: Course['id']
@Column(DataType.STRING)
declare groupId: Course['id']
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
declare code: Course['code']
@@ -38,29 +42,17 @@ export class CourseModel extends Model implements Course {
@Column(DataType.STRING)
declare coursetypecode: Course['coursetypecode']
@BelongsTo(() => CourseTypeModel, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
declare courseType: Course['courseType']
@HasMany(() => CreditModel, { foreignKey: 'course_id', sourceKey: 'id' })
declare credits: Course['credits']
@HasMany(() => EnrollmentModel, { foreignKey: 'course_id' })
declare enrollments: Course['enrollments']
@BelongsToMany(() => OrganizationModel, () => CourseProviderModel, 'coursecode')
declare organizations: Course['organizations']
@Column(DataType.BOOLEAN)
declare is_study_module: Course['is_study_module']
declare isStudyModule: Course['isStudyModule']
@Column(DataType.JSONB)
declare name: Course['name']
@Column(DataType.DATE)
declare max_attainment_date: Course['max_attainment_date']
declare maxAttainmentDate: Course['maxAttainmentDate']
@Column(DataType.DATE)
declare min_attainment_date: Course['min_attainment_date']
declare minAttainmentDate: Course['minAttainmentDate']
@CreatedAt
@Column({ field: 'created_at', type: DataType.DATE })
@@ -71,14 +63,31 @@ export class CourseModel extends Model implements Course {
declare updatedAt: Course['updatedAt']
@Column(DataType.JSONB)
declare substitutions: Course['substitutions']
declare validityPeriod: Course['validityPeriod']
@Column(DataType.JSONB)
declare substitution_groups: Course['substitution_groups']
declare substitutionGroups: Course['substitutionGroups']
@Column(DataType.BOOLEAN)
declare isPrimary: Course['isPrimary']
@Column(DataType.STRING)
declare course_unit_type: Course['course_unit_type']
declare courseUnitType: Course['courseUnitType']
@Column({ field: 'main_course_code', type: DataType.STRING })
declare mainCourseCode: Course['mainCourseCode']
/* RELATIONS */
@BelongsTo(() => CourseTypeModel, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
declare courseType: Course['courseType']
@HasMany(() => CreditModel, { foreignKey: 'course_id', sourceKey: 'id' })
declare credits: Course['credits']
@HasMany(() => EnrollmentModel, { foreignKey: 'course_id' })
declare enrollments: Course['enrollments']
@BelongsToMany(() => OrganizationModel, {
through: () => CourseProviderModel,
foreignKey: 'coursecode',
sourceKey: 'groupId',
})
declare organizations: Course['organizations']
}
@@ -13,6 +13,7 @@ import { OrganizationModel } from './organization'
export class CourseProviderModel extends Model implements CourseProvider {
@ForeignKey(() => CourseModel)
@Column(DataType.STRING)
// TODO: RENAME: this is courseGroupId, not code
declare coursecode: CourseProvider['coursecode']
@Column(DataType.JSONB)
@@ -20,6 +21,7 @@ export class CourseProviderModel extends Model implements CourseProvider {
@ForeignKey(() => OrganizationModel)
@Column(DataType.STRING)
// TODO: RENAME: this is organizationId, not code
declare organizationcode: CourseProvider['organizationcode']
@CreatedAt
@@ -19,6 +19,15 @@ export class CustomPopulationSearchModel extends Model implements CustomPopulati
@Column(DataType.STRING)
declare name: CustomPopulationSearch['name']
@Column(DataType.STRING)
declare mode: CustomPopulationSearch['mode']
@Column(DataType.JSONB)
declare programmes: CustomPopulationSearch['programmes']
@Column(DataType.STRING)
declare year: CustomPopulationSearch['year']
@Column(DataType.ARRAY(DataType.STRING))
declare students: CustomPopulationSearch['students']
}
@@ -17,7 +17,7 @@ export class ExcludedCourseModel extends Model implements ExcludedCourse {
declare programme_code: ExcludedCourse['programme_code']
@Column(DataType.STRING)
declare course_code: ExcludedCourse['course_code']
declare course_group_id: ExcludedCourse['course_group_id']
@Column(DataType.STRING)
declare curriculum_version: ExcludedCourse['curriculum_version']
@@ -17,22 +17,22 @@ export class ProgressCriteriaModel extends Model implements ProgressCriteria {
declare curriculumVersion: ProgressCriteria['curriculumVersion']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearOne: ProgressCriteria['coursesYearOne']
declare courseGroupIdsYearOne: ProgressCriteria['courseGroupIdsYearOne']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearTwo: ProgressCriteria['coursesYearTwo']
declare courseGroupIdsYearTwo: ProgressCriteria['courseGroupIdsYearTwo']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearThree: ProgressCriteria['coursesYearThree']
declare courseGroupIdsYearThree: ProgressCriteria['courseGroupIdsYearThree']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearFour: ProgressCriteria['coursesYearFour']
declare courseGroupIdsYearFour: ProgressCriteria['courseGroupIdsYearFour']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearFive: ProgressCriteria['coursesYearFive']
declare courseGroupIdsYearFive: ProgressCriteria['courseGroupIdsYearFive']
@Column(DataType.ARRAY(DataType.STRING))
declare coursesYearSix: ProgressCriteria['coursesYearSix']
declare courseGroupIdsYearSix: ProgressCriteria['courseGroupIdsYearSix']
@Column(DataType.INTEGER)
declare creditsYearOne: ProgressCriteria['creditsYearOne']
+5 -1
View File
@@ -45,7 +45,11 @@ export class OrganizationModel extends Model implements Organization {
@HasMany(() => SISStudyRightModel, { foreignKey: 'facultyCode', sourceKey: 'code' })
declare SISStudyRights: Organization['SISStudyRights']
@BelongsToMany(() => CourseModel, () => CourseProviderModel, 'organizationcode')
@BelongsToMany(() => CourseModel, {
through: () => CourseProviderModel,
foreignKey: 'organizationcode',
targetKey: 'groupId',
})
declare courses: Organization['courses']
@CreatedAt
+2
View File
@@ -9,6 +9,7 @@ import * as auth from './middleware/auth'
import currentUserMiddleware from './middleware/currentUser'
import errorHandler from './middleware/errorHandler'
import headersMiddleware from './middleware/headers'
import { testTimeMiddleware } from './middleware/testTime'
import banners from './routes/banners'
import changelog from './routes/changelog'
import closeToGraduation from './routes/closeToGraduation'
@@ -43,6 +44,7 @@ const routes = (app: Express, url: string) => {
app.use(compression())
app.use(headersMiddleware)
app.use(testTimeMiddleware)
app.use(currentUserMiddleware)
app.use(accessLogger)
app.use(`${url}/login`, login)
+4 -3
View File
@@ -3,6 +3,7 @@ import { Router } from 'express'
import { Release } from '@oodikone/shared/types'
import { isDev, isTest } from '../config'
import { now } from '../util/clock'
const router = Router()
@@ -18,19 +19,19 @@ router.get<never, Release[]>('/', async (_, res) => {
{
description: '**Feature 1**\n- Added a fancy new feature \n\n**Feature 2**\n- Fixed a bug\n- Fixed another bug',
title: 'Release 3',
time: new Date().toISOString(),
time: now().toISOString(),
version: '0.0.3',
},
{
description: "Let's not spam the GitHub API in development!",
title: 'Release 2',
time: new Date().toISOString(),
time: now().toISOString(),
version: '0.0.2',
},
{
description: 'This release should not be visible on the frontpage',
title: 'Release 1',
time: new Date().toISOString(),
time: now().toISOString(),
version: '0.0.1',
},
]
@@ -7,7 +7,7 @@ import { hasFullAccessToStudentData } from '../util'
const router = Router()
router.post<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (req, res) => {
const { programmeCode, courseCodes, curriculumVersion } = req.body
const { programmeCode, courseGroupIds, curriculumVersion } = req.body
const { roles, programmeRights } = req.user
const hasFullAccess = hasFullAccessToStudentData(roles)
@@ -15,7 +15,7 @@ router.post<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (r
if (!hasFullAccess && !hasAccessToProgramme) return res.status(403).end()
const result = await addExcludedCourses(courseCodes, curriculumVersion, programmeCode)
const result = await addExcludedCourses(courseGroupIds, curriculumVersion, programmeCode)
if (!result) {
res.status(400).end()
return
@@ -24,7 +24,7 @@ router.post<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (r
})
router.delete<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (req, res) => {
const { programmeCode, courseCodes, curriculumVersion } = req.body
const { programmeCode, courseGroupIds, curriculumVersion } = req.body
const { roles, programmeRights } = req.user
const hasFullAccess = hasFullAccessToStudentData(roles)
@@ -32,7 +32,7 @@ router.delete<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async
if (!hasFullAccess && !hasAccessToProgramme) return res.status(403).end()
const result = await removeExcludedCourses(courseCodes, curriculumVersion, programmeCode)
const result = await removeExcludedCourses(courseGroupIds, curriculumVersion, programmeCode)
if (!result) {
res.status(400).end()
return
+16 -34
View File
@@ -8,8 +8,9 @@ import {
CoursesMultiQuery,
CourseYearlyStatsReqBody,
CourseYearlyStatsQuery,
CourseDetails,
CourseDetailsQuery,
} from '@oodikone/shared/routes/courses'
import type { CourseWithSubsDetails } from '@oodikone/shared/types/course'
import { getCourseDetails, getCourseYearlyStats } from '../services/courses'
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
import {
@@ -18,49 +19,31 @@ import {
hasFullAccessToStudentData,
validateParamLength,
} from '../util'
import { CourseYearlyStats } from '@oodikone/shared/types/courseYearlyStats'
const router = Router()
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
'/coursesmulti',
async (req, res) => {
const { name, code, includeSpecial } = req.query
const { name, code } = req.query
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
}
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
const mergedCourses = new Map<string, CourseWithSubsDetails>()
for (const course of courses) {
if (!(course.max_attainment_date && course.min_attainment_date)) continue
if (!mergedCourses.has(course.code)) mergedCourses.set(course.code, structuredClone(course))
const mergedCourse = mergedCourses.get(course.code)!
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
mergedCourse.max_attainment_date = course.max_attainment_date
}
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
mergedCourse.min_attainment_date = course.min_attainment_date
}
}
res.json({ courses: Array.from(mergedCourses.values()) })
const courses = await getCoursesByNameAndOrCode(name, code)
res.json({ courses }).end()
}
)
export type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
export type CourseYearlyStatsResBody = CourseYearlyStats[]
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
'/courseyearlystats',
async (req, res) => {
const { codes, combineSubstitutions, separate, fromYearCode, toYearCode } = req.query
const { courses, substitutions, separate, fromYearCode, toYearCode } = req.query
const courseCodes = handleQueryArrays(codes)
if (!courseCodes?.length) {
if (!courses?.length) {
return res.status(422).send({ error: 'Missing required query parameters' })
}
@@ -79,14 +62,13 @@ router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody,
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
const useCombineSubstitutions = combineSubstitutions !== 'false'
const useSeparate = separate === 'true'
const results = await getCourseYearlyStats(
courseCodes,
handleQueryArrays(courses),
useSeparate,
anonymizationSalt,
useCombineSubstitutions,
substitutions === 'true',
fromYearCode,
toYearCode
)
@@ -94,12 +76,12 @@ router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody,
}
)
router.get<never, CanError<unknown>, never, { codes: string | string[] }>('/coursedetails', async (req, res) => {
const { codes: coursecodes } = req.query
const codes = handleQueryArrays(coursecodes)
router.get<never, CanError<CourseDetails>, never, CourseDetailsQuery>('/coursedetails', async (req, res) => {
const { courses } = req.query
const courseIds = handleQueryArrays(courses)
if (!codes?.length) return res.status(500).end()
const details = await getCourseDetails(codes)
if (!courseIds.length) return res.status(422).send({ error: 'Missing required parameters' })
const details = await getCourseDetails(courseIds)
return res.json(details)
})

Some files were not shown because too many files have changed in this diff Show More