Compare commits

...
635 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
Aaro KoivukangasandGitHub 6dd9662276 [CI][Updater] Replace eslint with lint 2026-07-28 19:59:58 +03:00
Aaro KoivukangasandGitHub 44bebc6fc3 [E2E][NumbersMatch] Disable tests and wait for playwright 2026-07-28 15:53:18 +03:00
Aaro KoivukangasandGitHub e9140af10d [E2E][Users] Skip the correct test step 2026-07-28 15:10:40 +03:00
Aaro Koivukangas 773998c9b9 [E2E] Skip broken tests, enable again when migrated to playwright 2026-07-28 14:31:17 +03:00
joonas-a e37acc2d81 [Studentdetails] prevent crash on no startDate studyrights 2026-07-28 14:17:08 +03:00
joonas-a 5964247bd6 [Logger] fix warning not logged 2026-07-27 15:37:09 +03:00
joonas-a 0a0b9641b5 [Infobox] Update 2026-07-27 14:43:00 +03:00
joonas-a 8851c68fc0 [Students by starting year] perform column span alignment 2026-07-27 14:43:00 +03:00
joonas-a 206226590d [Backend] add cancelled col to "by starting year" students 2026-07-27 14:43:00 +03:00
Aaro Koivukangas 15bcc7b30d [GithubActions][UpdaterUpdate] Scale workers down to 3 2026-07-27 14:35:17 +03:00
Aaro Koivukangas 1c845dd967 [GithubActions][UpdaterUpdate] Change npm to plain docker compose logs 2026-07-27 14:20:03 +03:00
Aaro Koivukangas 97fbed26a9 [GithubActions] Scale sis-updater-worker to 5 when running updater updates 2026-07-27 14:07:08 +03:00
Aaro Koivukangas 349948ed52 [GithubActions] Add docker compose teardown to action 2026-07-27 13:55:05 +03:00
Aaro Koivukangas 2983c7ae15 [GithubActions] Remove cypress cache action, disable pull docker image action 2026-07-27 13:35:03 +03:00
Aaro Koivukangas 58bbf10e60 [GithubActions] Add runner environment as a variable + move staging to self-hosted 2026-07-27 13:29:58 +03:00
Aaro Koivukangas 8e4bf19b1d [GithubActions] Typo fix 2026-07-27 13:23:17 +03:00
Aaro Koivukangas 5b12edadc9 [GithubActions] Handle github context properly 2026-07-27 13:15:38 +03:00
Aaro Koivukangas e8e4d095b6 [E2E][DegreeProgrammeOverview] Add cancelled column to tests 2026-07-27 12:56:06 +03:00
Aaro Koivukangas 6115357a7c [Backend][Populations] Don't filter population's students' credits and enrollments by start date by default 2026-07-26 22:02:52 +03:00
Aaro Koivukangas 2672ac8b63 [GithubActions] Use correct github variable name 2026-07-26 21:38:07 +03:00
Aaro Koivukangas b9c536c99b [GithubActions] Enable (again again) self-hosted 2026-07-26 21:35:23 +03:00
Aaro Koivukangas 1e350ed4be [Shared][UnitTests] Add vitest config for even faster unit tests 2026-07-26 16:36:25 +03:00
Aaro Koivukangas 75a65693d4 [GithubActions] Typo fix 2026-07-26 16:26:12 +03:00
Aaro Koivukangas 4b03769a50 [GithubActions] Use correct syntax for variables 2026-07-26 16:25:33 +03:00
Aaro Koivukangas 8b51804962 [GithubActions] Remove unit tests from pull docker action 2026-07-26 16:20:57 +03:00
Aaro Koivukangas 092def09ea [GithubActions] Add unit tests as a low level dependency 2026-07-26 16:19:43 +03:00
Aaro Koivukangas b9667dbd12 [GithubActions] Typo fix 2026-07-26 16:13:42 +03:00
Aaro Koivukangas 301085b566 [GithubActions] Enable image caching when not using self-hosted runners 2026-07-26 16:12:53 +03:00
Aaro Koivukangas 22a4ae17f6 [GithubActions] Change (yet again) back to gh runners 2026-07-26 16:10:32 +03:00
Aaro Koivukangas dc062cd349 [Backend][Utils] Add tests for hasFullAccessToStudentData 2026-07-26 16:00:32 +03:00
Aaro Koivukangas 513981674a [Backend][Utils] Add unit tests for parseDateRangeFromParams 2026-07-26 16:00:10 +03:00
Aaro Koivukangas 771cf5ef81 [GithubActions] Try to move cypress videos to writeable location 2026-07-26 14:33:17 +03:00
Aaro Koivukangas 13fb1e11ba [ServiceTests] Use global year <-> yearCode helper 2026-07-26 14:31:30 +03:00
Aaro Koivukangas caa28c2b19 [Shared][UnitTests] Add tests for year <-> yearCode helper functions 2026-07-26 14:31:05 +03:00
Aaro Koivukangas a8a1c06c2e [Shared][UnitTests] Add unit tests to shared 2026-07-26 14:30:21 +03:00
Aaro Koivukangas 3cecd7d20b [GithubActions] Disable cypress image caching, no need 2026-07-26 13:17:52 +03:00
Aaro Koivukangas ed5cb01c70 [GithubActions] Enable self-hosted runner again 2026-07-26 13:16:34 +03:00
Aaro Koivukangas 433ec96264 [E2E] Fix broken e2e test numbers 2026-07-25 16:16:48 +03:00
Aaro Koivukangas 40be964e71 [Backend][StudyProgramme] Add missing type to function 2026-07-25 16:16:35 +03:00
Aaro Koivukangas b1ee1d206f [GithubActions][E2E] Remove --user 1001 flag from e2e, possibly breaking tests 2026-07-25 14:40:37 +03:00
Aaro Koivukangas 97099ebb5d [GithubActions] Use correct check for cache hit 2026-07-25 14:18:22 +03:00
Aaro Koivukangas a728f86927 [GithubActions] More syntax error fixes 2026-07-25 14:15:22 +03:00
Aaro Koivukangas e916e8b378 [GithubActions] Syntax error fix 2026-07-25 14:12:53 +03:00
Aaro Koivukangas e7a8e09ed4 [GithubActions] Don't re-fetch cached data if it already exists 2026-07-25 14:11:36 +03:00
Aaro Koivukangas 035ca8f1d9 [GithubActions] Enable cypress cache action 2026-07-24 18:36:55 +03:00
Aaro Koivukangas 7cc9151e3b [GithubActions] Change back to ubuntu-latest quickly 2026-07-24 18:03:50 +03:00
joonas-a 9ee5e51a6a [Infobox] enhancement 2026-07-24 17:48:57 +03:00
joonas-a f38bdca897 [Faculties] bring cancellations to basic stats 2026-07-24 17:48:57 +03:00
joonas-a efb980afac [Degree programme overview] bring cancelled students and dedupe typings 2026-07-24 17:48:57 +03:00
joonas-a 7644990f91 [Backend][Basic stats] add cancelled study rights to response 2026-07-24 17:48:57 +03:00
Aaro Koivukangas 41c4686a8c [GithubActions] Fix dependencies 2026-07-24 17:30:34 +03:00
Aaro Koivukangas ffa8a44faf [GithubActions] Change remaining workflows to use self-hosted (excluding pr for security reasons) 2026-07-24 17:30:20 +03:00
Aaro Koivukangas ee1a930f51 [GithubActions][E2E] Try to run e2e as non-root user 2026-07-24 17:23:41 +03:00
Aaro Koivukangas 25da52b1dd [GithubActions] Disable cache actions, pulls are faster 2026-07-24 17:23:25 +03:00
Aaro Koivukangas 0326b21a5b [GithubActions] Remove unnecessary dependencies from updater update action 2026-07-24 17:09:26 +03:00
Aaro Koivukangas 3e4a1cc941 [GithubActions] Try out self-hosted runners, again. 2026-07-24 16:56:23 +03:00
Aaro Koivukangas 3b629534a4 [Tests] Fix e2e and api tests to handle new calculation logic 2026-07-24 16:50:54 +03:00
Aaro Koivukangas 4fa7fb1a10 [CoursePopulation] Make course population calculations respect selected timeframe 2026-07-24 16:50:07 +03:00
Aaro Koivukangas 6e2c66045c [Lint] Lint fix 2026-07-24 15:18:26 +03:00
Aaro Koivukangas 6fc614f679 [E2E] Add e2e tests to check that numbers match for single years 2026-07-24 15:13:47 +03:00
Aaro Koivukangas c41f091869 [ProgrammeCourses] Make stats query respect selected years 2026-07-24 15:13:46 +03:00
Aaro Koivukangas c6b598de25 [ProgrammeCourses] Add query params to set from and to range when querying programme courses 2026-07-24 15:13:46 +03:00
Aaro Koivukangas 4d5f6b07ab [Utils] Add better defaults to year <-> yearCode transition 2026-07-24 15:13:45 +03:00
joonas-a e79cc5f4ab [Chore] update dependencies 2026-07-24 12:52:37 +03:00
joonas-a 8091d21405 [Filter toggle] fix issue with tooltip on disabled state component 2026-07-23 14:11:05 +03:00
joonas-a 7ac1541bcb [Faculties] match table style with that of programme overview 2026-07-23 13:57:16 +03:00
joonas-a 71ba58c83e [Study track stats] add vertical lines to table 2026-07-23 13:53:09 +03:00
Aaro Koivukangas 9d2df861ba [GithubActions] Add cache missing safeguard to e2e tests 2026-07-23 13:49:13 +03:00
Aaro Koivukangas 9b2708e989 [GithubActions] Use correct syntax 2026-07-23 13:37:06 +03:00
Aaro Koivukangas 24fe973467 [GithubActions] Add missing dependencies 2026-07-23 13:31:23 +03:00
Aaro Koivukangas 4522e9aeba [GithubActions] Remove unnecessary job 2026-07-23 13:30:03 +03:00
Aaro Koivukangas 5ce1679462 [GithubActions] Streamline dependencies 2026-07-23 13:15:21 +03:00
Aaro Koivukangas bde86054c7 [GithubActions] Use correct boolean checking 2026-07-23 13:08:38 +03:00
Aaro Koivukangas 5dfff69177 [GithubActions] More syntax error fixes 2026-07-23 13:01:32 +03:00
Aaro Koivukangas 349f296285 [GithubActions] Fix syntax error 2026-07-23 13:00:49 +03:00
Aaro Koivukangas f4e822aafc [GithubActions] Try to make docker cache more robust 2026-07-23 12:57:23 +03:00
Aaro Koivukangas 41ede1d221 [GithubActions] Add metadata to docker images 2026-07-23 12:39:43 +03:00
Aaro Koivukangas 110e9d47bc [GithubActions] Change push variable to be a string, not a boolean 2026-07-23 12:01:08 +03:00
Aaro Koivukangas 4275a854bd [GithubActions][E2E] Remove multispec e2e tests 2026-07-23 11:18:41 +03:00
Aaro Koivukangas 1f83e06dee [GithubActions] Change to use buildx everywhere 2026-07-23 11:17:16 +03:00
Aaro Koivukangas bb6e9d3317 [GithubActions][E2E] Fix error with multispec e2e tests 2026-07-23 10:23:08 +03:00
Aaro Koivukangas af3ea08294 [E2E] Fix broken numbers 2026-07-23 10:21:35 +03:00
Aaro Koivukangas 7ea227078a [CourseStats] Fix links to courseStats to default combineSubstitutions=true 2026-07-23 10:07:37 +03:00
Aaro Koivukangas 1c9e481dcc [CourseStats] Fix links not updating on multiple courses selected 2026-07-23 10:07:04 +03:00
Aaro Koivukangas b8dbc8658f [GithubActions] Change Cypress image cache key to a more temporary one 2026-07-22 22:42:56 +03:00
Aaro Koivukangas f78b8420ed [BackendTests] Fix broken numbers in test 2026-07-22 22:37:43 +03:00
Aaro Koivukangas b3d2e2e6a8 [GithubActions] Typo fix 2026-07-22 22:32:47 +03:00
Aaro Koivukangas 482f6840e0 [GithubActions] Restore accidentally deleted dependency 2026-07-22 22:31:09 +03:00
Aaro Koivukangas b3570c72ca [GithubActions] Also cache cypress image 2026-07-22 22:29:40 +03:00
Aaro Koivukangas eba0fc3669 [GithubActions] Add missing shell variable to actions 2026-07-22 22:07:46 +03:00
Aaro Koivukangas a4984a9f58 [GithubActions] Add caching to every step 2026-07-22 21:47:00 +03:00
Aaro Koivukangas 4fc3d1e1d7 [GithubActions] Check if caching would work 2026-07-22 21:34:42 +03:00
Aaro Koivukangas feb3c43e0b [GithubActions] Fix typo 2026-07-22 21:09:21 +03:00
Aaro Koivukangas eb9e7b7260 [GithubActions][Cypress] Change cypress to run inside docker container 2026-07-22 21:03:41 +03:00
Aaro Koivukangas c1472da8f5 [Cypress] Make cy.intercept intercept all requests 2026-07-22 21:03:21 +03:00
Aaro Koivukangas 199f17999b [GithubActions] Typo fix 2026-07-22 18:08:44 +03:00
Aaro Koivukangas 8cb7a33b14 [GithubActions] Explicitly install cypress inside cypress action 2026-07-22 17:32:56 +03:00
Aaro Koivukangas bfffe247d4 [CI/E2E] Update cypress version 2026-07-22 17:20:22 +03:00
Aaro Koivukangas 964a9b458b [Npm] Allow cypress to run postinstall scripts 2026-07-22 17:09:10 +03:00
renovate[bot]andAaro Koivukangas ea525a0921 Update actions/setup-node action to v7 2026-07-22 16:51:58 +03:00
Joonas AhovalliandGitHub d81b0abbea [README] update
Clarify the description for clearing node_modules.
2026-07-22 16:43:47 +03:00
joonas-a 207001398b [README] update 2026-07-22 16:42:39 +03:00
Aaro Koivukangas 4db4e9f4ff [GithubActions][E2E] Remove Cypress install step, doesn't do anything 2026-07-22 16:41:48 +03:00
joonas-a 4c5391bfe1 [README] update 2026-07-22 16:34:57 +03:00
joonas-a 7d3176fdd6 [vite config] remove line/col logging as non-functional 2026-07-22 16:34:57 +03:00
renovate[bot]andAaro Koivukangas e15e941fde Update actions/checkout action to v7 2026-07-22 16:09:37 +03:00
joonas-a eb8d0bc2c9 [Scripts] Delete docker "npm install" scripts"
We only want to run npm ci in container, as the package-locks are those
of host, and if container were to write over them the permissions would
get messed up
2026-07-22 16:05:11 +03:00
joonas-a 560322baa8 [Chore] Fix issues with react hooks
..many more still remain:-(
2026-07-22 16:03:23 +03:00
joonas-a 63c289adb4 [tsconfig] make consistent with the rest of them 2026-07-22 16:03:23 +03:00
joonas-a 8043dca022 [lint] config cleanup 2026-07-22 16:03:23 +03:00
Aaro Koivukangas 0bd97e11d0 [GithubActions] Typo fix 2026-07-22 15:52:06 +03:00
Aaro Koivukangas edd62ee7f6 [GithubActions] Install cypress during each e2e test job 2026-07-22 15:49:28 +03:00
Aaro Koivukangas 8ce8382511 [E2E][NumbersMatch] Don't use magic year codes 2026-07-22 15:10:08 +03:00
Aaro Koivukangas 9fcc5eb950 [CoursePopulation] Change to use same calculation logic as elsewhere 2026-07-22 15:10:08 +03:00
Aaro Koivukangas 3258ccf670 [CourseStats] Fix error loading stats, null has no method getTime() 2026-07-22 15:10:07 +03:00
Aaro Koivukangas 72c43de518 [ApiTests] Change /courseyearlystats to use year codes 2026-07-22 15:10:07 +03:00
Aaro Koivukangas 921eb894ba [CourseStats] Fetch coursestats based on selected years + change to use year codes 2026-07-22 15:10:07 +03:00
Aaro Koivukangas 26a85eba71 [Backend][Population] Reorder maxYearsToCreatePopulationFrom return values 2026-07-22 15:10:06 +03:00
Aaro Koivukangas e4ad5ba606 [CourseStatistics] Add few non-impactful tests 2026-07-22 15:10:06 +03:00
Aaro Koivukangas 44468584df [GithubActions] Remove manual caching, just change cache key 2026-07-22 15:10:06 +03:00
Aaro Koivukangas dccad4529f [GithubActions] Add custom cache key to cypress 2026-07-22 14:54:25 +03:00
Aaro Koivukangas 080d131053 [GithubActions] Properly install cypress and save it to cache 2026-07-22 13:42:32 +03:00
Aaro Koivukangas c8bf135880 [GithubActions] Try to cache cypress 2026-07-22 13:20:23 +03:00
joonas-a a837bb778e [Theme] Proper module augmentation typing 2026-07-21 15:18:43 +03:00
joonas-a b27bb1d030 [Lint] perform typecheck when linting by default 2026-07-21 14:53:24 +03:00
joonas-a c6330d494f Typescript 7 2026-07-21 14:45:56 +03:00
joonas-a 19a25f8715 [Lint-staged] big speed improvement 2026-07-21 13:30:16 +03:00
joonas-a d5e7d5c253 [Lint-staged] update 2026-07-21 13:17:56 +03:00
joonas-a fb68c4c6db [Meta] Swap eslint to oxlint 2026-07-21 13:17:56 +03:00
joonas-a aefcbfb1b7 [Lint] fix errors 2026-07-21 13:17:56 +03:00
joonas-a 9d253e9d84 [Typescript] fix deprecated baseUrl to prep for ts 7 2026-07-21 13:17:56 +03:00
joonas-a a014083302 [Meta] add oxfmt
faster prettier to make precommit hook less painful
2026-07-21 13:17:56 +03:00
joonas-a 00a9b35e92 [React] Enable react compiler
Using annotation mode. To enable for a function, type "use memo" as the
first line in function body. (or hook).
2026-07-21 13:17:56 +03:00
joonas-a 64d9fb1fc1 [React] fix illegal hook naming
Rules of hooks #1: Hook names start with "use". react compiler is not
happy.
2026-07-21 13:17:56 +03:00
Aaro Koivukangas 303163cb64 [GithubActions] Revert docker runner :( 2026-07-20 12:38:45 +03:00
Aaro Koivukangas 85f4b535e7 [GithubActions] Test new docker runners 2026-07-20 10:57:11 +03:00
Aaro Koivukangas f139f13e3b [Backend][CourseStatistics] Change student count calculation logic to respect selected year 2026-07-20 10:50:09 +03:00
joonas-a d7c0cf975a [CLI] add new option to nuke nodemodules 2026-07-17 10:12:45 +03:00
joonas-a 1c131fd9e2 [MADNESS] end SEKOILU with dev container permissions 2026-07-16 19:05:41 +03:00
Santeri Rajamäki 75d3269282 [OT] Spread TData and TValue 2026-07-16 17:36:00 +03:00
joonas-a 2a89f37984 [Docker][Dev] fix permissions with node_modules................... 2026-07-16 16:32:20 +03:00
joonas-a 05a9c1f0d1 [Docker] madness. ubi9->ubi10. 2026-07-16 14:39:55 +03:00
Santeri Rajamäki 31bb53b885 [META] Update cli scripts 2026-07-16 12:41:36 +03:00
joonas-a f1120cc6ce [Meta][Frontend] allow root level package(s) from remote rather than npm
We use xlsx which is distributed by their own cdn rather than npm. Npm
v12 by defaults blocks this installation.
2026-07-16 11:27:46 +03:00
joonas-a 4754feb6af [Meta][Frontend] Vite v8, react plugin swc->oxc 2026-07-16 11:27:46 +03:00
Santeri Rajamäki 2ebfb44571 [Frontend][CloseToGraduation] Remove function pointers from memo 2026-07-15 17:09:40 +03:00
Santeri Rajamäki df71cc955c [Frontend][CloseToGraduation] Remove early exit from semesterEnrollment map 2026-07-15 17:08:44 +03:00
Santeri Rajamäki 373f870854 [Frontend][Export] Remove unneccesary array spreads from faculty students by year 2026-07-15 17:07:19 +03:00
Santeri Rajamäki 2ac369daa4 [Frontend] Cleanup semestersPresent call sites 2026-07-15 16:54:24 +03:00
Santeri Rajamäki 018a73e760 [Frontend] Cleanup semestersPresent format util 2026-07-15 16:53:03 +03:00
Santeri Rajamäki f43a4e4397 [Frontend] Convert semestersPresent helper to use formtting utils like the rest of the views 2026-07-15 16:52:20 +03:00
Santeri Rajamäki 958258ce01 [Dockerfile] Use --include and --omit correctly 2026-07-15 16:03:03 +03:00
Santeri Rajamäki e039c78f9e [OT] Detach header mapping 2026-07-15 14:50:39 +03:00
Santeri Rajamäki 6784b6e682 [OT] Rework SortIcons 2026-07-15 14:48:24 +03:00
Santeri Rajamäki cc85b5994a [Frontend][Redux] Drop TODO as not needed 2026-07-15 14:21:40 +03:00
Santeri Rajamäki d5af6977c5 [META] Remove deprecated language center switch 2026-07-15 13:19:51 +03:00
joonas-a dc0b5eb890 [React] enable StrictMode 2026-07-15 12:30:27 +03:00
joonas-a e47db461bb [Infobox][Programme overview] fix text formatting on combined programme(s) 2026-07-15 11:45:42 +03:00
joonas-a 429980e883 [Infobox][By starting year] mention "has recent attainment" column 2026-07-15 11:44:24 +03:00
joonas-a a8f4600875 [Graduation times] reword helper text 2026-07-15 11:33:15 +03:00
joonas-a 6cb063d717 [Frontend] inconsistent layout fixes 2026-07-15 11:30:21 +03:00
joonas-a a79cf865e4 [Langcenterview] make great again
Big UI tweaks.
2026-07-14 16:15:35 +03:00
joonas-a d8342371eb [Section] enhance
do not render jank when no need
2026-07-14 15:51:56 +03:00
joonas-a a2f9450eb5 [Overview][Lang center] fix atrocious layouts 2026-07-14 15:35:05 +03:00
joonas-a d4253141e6 [Programme overview] layout / padding / overlap tweaks 2026-07-14 14:21:28 +03:00
joonas-a 07dc603460 [Faculties] more UI tweaks and consistency 2026-07-14 12:38:27 +03:00
joonas-a 1877be265e [StyledTable] add nowrap as a flag 2026-07-14 12:34:21 +03:00
joonas-a a47c3c809d [Faculties][By starting year] table layout tweaks 2026-07-14 12:03:03 +03:00
joonas-a 7ce91ca38d [Faculties][Basic info] increase rendering performance 1000x 2026-07-14 10:43:33 +03:00
joonas-a 26cc77e68e [Banners] cleanup 2026-07-14 10:09:56 +03:00
joonas-a 183bdbfa45 [Frontend][Navigation] make BanMan accessible to admins 2026-07-13 16:49:38 +03:00
joonas-a 4bdbf4ad4d [Frontend] new admin view to manage banners (BanMan) 2026-07-13 16:49:38 +03:00
joonas-a 826dd15fd3 [BannerView] load + show existing banners at the top 2026-07-13 16:49:38 +03:00
joonas-a faf8fb5d01 [RTKapi] inject endpoints for banner queries/mutations 2026-07-13 16:49:38 +03:00
joonas-a 975959f1a5 [Backend] use banner routes + include banners in /login response 2026-07-13 16:49:38 +03:00
joonas-a 249b5ff94e [Backend] create routes/services for banners 2026-07-13 16:49:38 +03:00
joonas-a fdc860ffd7 [Kone] add model/migration for upcoming banners 2026-07-13 16:49:38 +03:00
joonas-a 15cd86cc47 [Theme] add "dark" (black) color 2026-07-13 16:49:38 +03:00
Santeri Rajamäki 3c5d76bc43 [UPDATER][WORKER] Strip initStartYearToSemesters 2026-07-13 15:41:10 +03:00
Santeri Rajamäki 6dba913e0c [UPDATER][WORKER] Cleanup initStartYearToSemesters 2026-07-13 15:38:52 +03:00
Santeri Rajamäki 3ea337e43e [Frontend][DegreeCourses] Fix. 2026-07-13 15:20:08 +03:00
Santeri Rajamäki 513b15fed5 [CLI] Reword option 2 2026-07-13 14:12:35 +03:00
Santeri Rajamäki 52d672b99b [Frontend][StudyProgramme] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 855121b5af [Frontend][Students] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 27b812f8cf [Frontend][CourseStats] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 3225fc1006 [Backend][Teachers] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki ea40c2a56b [Backend][StudyProgramme] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 342048619a [Backend][University] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 8c7c130672 [Updater] Collapse if init 2026-07-13 13:26:45 +03:00
Santeri Rajamäki 681263aac4 [Frontend][Faculty] Collapse if init 2026-07-13 13:26:45 +03:00
Aaro Koivukangas 686438992f [E2E][Filters] Search study track by name, not by index 2026-07-13 13:00:42 +03:00
Aaro Koivukangas 4dcccdfab3 [E2E][University] Use the config variable name 2026-07-13 11:52:28 +03:00
Santeri Rajamäki ef5985d290 [E2E] Add access right checking to yaml 2026-07-13 11:50:05 +03:00
Santeri Rajamäki 0bfe4bace5 [E2E][Routes] Forbid "Something broke" when user has access 2026-07-13 11:36:41 +03:00
Santeri Rajamäki a7f285c5cb [E2E][Routes] Add server error catcher 2026-07-13 11:36:41 +03:00
Santeri Rajamäki 324db4c837 [E2E] Add access checking 2026-07-13 11:36:41 +03:00
Aaro Koivukangas 150b86145d [E2E][University] Add hella-long timeout to university e2e tests 2026-07-13 10:50:44 +03:00
Aaro Koivukangas 18f61cdb90 [E2E][University] Add same kind of check for Average graduation times breakdown chart 2026-07-12 17:04:12 +03:00
Aaro Koivukangas f99b882c4a [GithubActions] Still cannot enable updater update action 2026-07-12 14:17:34 +03:00
Aaro Koivukangas d525bc8673 [GithubActions] Add shorter retention dates to artifact uploads to use less storage 2026-07-11 09:54:39 +03:00
Aaro Koivukangas 1afbd6f2b4 [E2E][University] Add check that data has loaded 2026-07-11 09:50:21 +03:00
Aaro Koivukangas e3110219b6 [E2E] Fix degree programme overview test numbers 2026-07-11 09:48:39 +03:00
Aaro Koivukangas fb16335c81 [LanguageCenter] Fix sorting study rights with null end dates 2026-07-11 09:32:33 +03:00
Aaro Koivukangas f3fb9bc6da [Lint] Lint fix 2026-07-10 21:27:08 +03:00
Aaro Koivukangas a153b20fae [GithubActions] Re-enable updater update action acually updating sis-db 2026-07-10 21:11:14 +03:00
Aaro Koivukangas 159e904792 [E2E] Fix language center test numbers (for the last time... I hope) 2026-07-10 21:10:44 +03:00
Aaro Koivukangas 2b5b10b38b [LanguageCenter] Make language center view show correct faculty for students 2026-07-10 21:10:22 +03:00
Aaro Koivukangas 92fa9c3d2a [E2E] Fix faculty test numbers 2026-07-10 13:38:24 +03:00
Aaro Koivukangas 5d8b3e76ab [E2E] Fix course statistic test numbers 2026-07-10 13:38:07 +03:00
Aaro Koivukangas aacb2b2749 [Faculty] Fix test data studytrackstats calculations 2026-07-10 13:13:56 +03:00
Aaro Koivukangas fbe6fd4315 [Updater][Worker] Update all students by default locally 2026-07-10 12:52:43 +03:00
joonas-a 25e31c308a [Faculties] fix colspan to account for new column 2026-07-10 12:50:53 +03:00
Aaro Koivukangas 6034d885a0 [DockerCompose] Change ci-jami to use test NODE_ENV (even though it is not supported, just changing away from development) 2026-07-10 10:25:14 +03:00
Aaro Koivukangas bd9cfb9a7e [GithubActions][E2E] Run e2e tests in firefox 2026-07-10 10:17:54 +03:00
Aaro Koivukangas 24eb3137e9 [GithubActions] Add backend service-tests to gh actions 2026-07-10 10:12:07 +03:00
Aaro Koivukangas 5829f5c434 [Tests][Backend] Move unit tests that require db out of unit tests 2026-07-10 10:11:32 +03:00
joonas-a 9260a7d290 [Student page] Additionally highlight module and degree programme completions when a study right is selected 2026-07-09 16:11:20 +03:00
joonas-a 3adcb48e15 [Backend][Student details] Fetch modules in addition to courses for studyplans 2026-07-09 16:11:20 +03:00
Aaro Koivukangas b494af6344 [E2E] Add Course population tests to Numbers match 2026-07-09 15:41:50 +03:00
Aaro Koivukangas 6357960541 [ApiTests] Add api tests for Course population student logic 2026-07-09 15:41:50 +03:00
Aaro Koivukangas 73994181c8 [Backend][CourseStatistics] Change CoursePopulation student calculation logic to match other course calculations 2026-07-09 15:41:50 +03:00
Aaro Koivukangas 3bcab31d8e [Filters] Make Course-filter to respect includeSubstitutions 2026-07-09 15:41:49 +03:00
Aaro Koivukangas 951948e26d [Dockerfile] Ensure devDependencies are downloaded in updater dev environment 2026-07-09 15:41:49 +03:00
Aaro Koivukangas 06dc27e4fb [Vitest] Improve api test performance 2026-07-09 15:41:49 +03:00
Aaro Koivukangas 35872d6d26 [Updater][SubstitutionGroups] Remove possibly empty substitution groups 2026-07-09 15:41:48 +03:00
Aaro Koivukangas b142ed7339 [DockerCompose] Fix jami node_env to the correct one 2026-07-09 15:41:48 +03:00
joonas-a 2ffcfe7466 [StudyTrackStats] Scale colspans to account for new column 2026-07-09 15:19:41 +03:00
joonas-a 2a6dadf3dc [Backend][StudyTrackStats] add "Has recent attainment" to response 2026-07-09 15:19:04 +03:00
joonas-a 06c4b4cfae [Backend][StudyRightsFinders] include more data with studyrights when "include credits" mode enabled 2026-07-09 15:08:30 +03:00
Aaro Koivukangas 065922564c [GithubActions] Fix typo in frontend release action 2026-07-08 09:25:33 +03:00
Aaro Koivukangas c7d0e39eeb [E2E] Test numbers fix (pls be the last...) 2026-07-08 09:10:49 +03:00
Aaro Koivukangas 25418f700b [E2E] Fix e2e test numbers 2026-07-07 18:02:54 +03:00
Aaro Koivukangas 2439fc4475 [GithubActions] Fix hanging api test 2026-07-07 17:41:04 +03:00
Aaro Koivukangas df4601f0bf [Scripts] Improve oodikone:ci script + add comment about nginx in ci compose 2026-07-07 17:15:15 +03:00
Aaro Koivukangas 80516dd6a8 [E2E][LanguageCenter] Revert previous edits 2026-07-07 17:14:07 +03:00
Aaro Koivukangas 9d22511895 [GithubActions] Disable updater update pushing image to toska hub 2026-07-07 16:02:18 +03:00
Aaro Koivukangas 2bec5a0de1 [E2E][LanguageCenter] Fix broken e2e test 2026-07-07 15:38:10 +03:00
Aaro Koivukangas 734442c43c [GithubActions] Add missing failure + cancelled checks 2026-07-07 15:37:53 +03:00
Aaro Koivukangas 6ae1a05f27 [GithubActions] Remove log-flood from api tests 2026-07-07 15:19:57 +03:00
Aaro Koivukangas f11f2f240c [Scripts+DockerCompose] Update scripts used to run tests 2026-07-07 14:32:10 +03:00
Aaro Koivukangas 86d7f74cb2 [E2E][LanguageCenter] Fix broken e2e test 2026-07-07 13:57:23 +03:00
Aaro Koivukangas ceb7a733d7 [E2E][LanguageCenter] Fix broken e2e tests 2026-07-07 12:37:01 +03:00
Aaro Koivukangas c0246550bb [E2E][Teachers] Fix broken test 2026-07-07 12:14:26 +03:00
Aaro Koivukangas cb21c675c2 [E2E][DegreeProgrammeOverview] Fix tests broken by updater update 2026-07-07 12:13:44 +03:00
Aaro Koivukangas 49da59655e [E2E][Students] Fix tests broken by updater update 2026-07-07 10:27:11 +03:00
Aaro Koivukangas 39d9d6f664 [GithubActions] Make dependencies more clean 2026-07-06 16:51:15 +03:00
Aaro Koivukangas e3bd0d1226 [ApiTests] Update tests to match newly updated data 2026-07-06 16:50:27 +03:00
Aaro Koivukangas 795cb50a1f [GithubActions] Remove prepurge and purge actions, they never did anything 2026-07-06 14:44:07 +03:00
Aaro Koivukangas ba75b0828f [DockerCompose] Add env variables to scheduler to trigger updater updates locally easier 2026-07-06 13:58:16 +03:00
Aaro Koivukangas 0a0225bc77 [Scripts] Add a script to get all logs, including db 2026-07-06 13:58:15 +03:00
Aaro Koivukangas f14028145b [GithubActions] Restore previously deleted flag from healthchecks 2026-07-06 13:38:07 +03:00
Aaro Koivukangas 3cd7f05280 [GithubActions] Add log dump to updater update in case of failure 2026-07-06 13:06:25 +03:00
Aaro Koivukangas bc5769e89e [GithubActions] Last fixes to dependency structure 2026-07-06 12:59:00 +03:00
Aaro Koivukangas 85365e2636 [GithubActions] Fix permission error on updater update 2026-07-06 12:54:26 +03:00
Aaro Koivukangas b4deae4343 [DockerCompose] Fix healthchecks to all work the same way 2026-07-06 12:54:09 +03:00
Aaro Koivukangas 669e1b6ab9 [GithubActions] Fix actions dependency structure even more 2026-07-06 12:53:41 +03:00
Aaro Koivukangas 35c11c3e20 [GithubActions] Update dependency structure 2026-07-06 10:40:45 +03:00
Aaro Koivukangas fc61414f4b [GithubActions] Change to use more simple if block 2026-07-06 10:39:07 +03:00
Aaro Koivukangas 130f38c171 [Updater][Worker] Add throws to all errors + add a proper wait when running in EXIT_AFTER_IMMEDIATES 2026-07-06 10:27:01 +03:00
Aaro Koivukangas 98d32cfafc [DockerCompose] Fix database healthcheck logs No role for "root" 2026-07-06 10:25:36 +03:00
Aaro Koivukangas 8c85333cd6 [GithubActions] Add updater update to test workflow 2026-07-06 10:24:40 +03:00
Aaro Koivukangas febef1b7b8 [GithubActions] Remove unnecessary steps from build action 2026-07-05 11:15:52 +03:00
Aaro Koivukangas c9465da33d [GithubActions] Fix backend+frontend push to quay 2026-07-05 11:02:17 +03:00
Aaro Koivukangas 3a85da4573 [GithubActions] Add more missing quotes from build 2026-07-04 16:11:13 +03:00
Aaro Koivukangas 57d0aa6870 [GithubActions] Add missing quotes to backend+frontend build action 2026-07-04 15:48:39 +03:00
Aaro Koivukangas f00855f1bf [GithubActions] Export buildah image and tag name from action 2026-07-04 15:27:18 +03:00
Aaro Koivukangas 2c2393c422 [GithubActions] Update external action version 2026-07-04 15:03:23 +03:00
Aaro Koivukangas 677c0f2b59 [GithubActions] Fix simple typo in frontend and backend build action 2026-07-04 15:03:02 +03:00
Aaro Koivukangas 7ae1a84bd5 [UnitTesting] Add placeholder unit tests for backend to stop actions from failing 2026-07-04 14:48:33 +03:00
Aaro Koivukangas 2463ffa771 [GithubActions] Add missig checkout 2026-07-04 14:46:11 +03:00
Aaro Koivukangas 2fff045861 [GithubActions] Remove unnecesary logs from pulling images 2026-07-04 14:45:19 +03:00
Aaro Koivukangas 02aa86ae47 [GithubActions] Remove image pull caching so that the correct version for dbs are always used 2026-07-04 14:39:04 +03:00
Aaro Koivukangas 304da32284 [GithubActions] Fix multiple action errors 2026-07-04 14:37:56 +03:00
Aaro Koivukangas 76071c9f00 [GithubActions] Fix tests waiting for backend to start inside docker-compose.ci.yml 2026-07-04 14:35:16 +03:00
Aaro Koivukangas 6d4aeced89 [GithubActions] Fix e2e test action path change 2026-07-04 14:02:18 +03:00
Aaro Koivukangas ec32982ada [GithubActions] Add shell variable to all run: action steps 2026-07-04 14:00:34 +03:00
Aaro Koivukangas e68bffe8ec [GithubActions] Frontend build typo fix 2026-07-04 13:54:25 +03:00
Aaro Koivukangas 20875c6184 [GithubActions] Fix action error and use correct release_version var name 2026-07-04 13:51:05 +03:00
Aaro Koivukangas 67e67323de [GithubActions] Fix one more typo 2026-07-04 13:47:32 +03:00
Aaro Koivukangas 5d6b6b71ab [GithubActions] Fix typo and add names to workflow steps 2026-07-04 13:46:32 +03:00
Aaro Koivukangas 438267d552 [GithubActions] Handle checkouts correctly 2026-07-04 13:41:26 +03:00
Aaro Koivukangas dc02443986 [GithubActions] Fix multiple typos and errors 2026-07-04 13:35:36 +03:00
Aaro Koivukangas e3e7468f14 [GithubActions] Change backend action to use correct runner 2026-07-04 13:29:52 +03:00
Aaro Koivukangas fb0b4d01c4 [GithubActions] Fix typos 2026-07-04 13:22:34 +03:00
Aaro Koivukangas f0ebd6de93 [GithubActions] Rework github actions to be more consistent 2026-07-04 13:19:15 +03:00
Aaro Koivukangas f734c0db38 [DockerCompose] Add healthchecks for all applicable containers 2026-07-04 10:41:26 +03:00
joonas-a d8d34a58b1 [BullMQ][Backend] Fix logger not logging
There is no need to have a try/catch or logger instance in the
processor. The errors are already propagated back to where we had
existing event listeners.
2026-07-03 19:39:14 +03:00
Aaro Koivukangas 20433e72b7 [ApiTests] Skip few tests, will be fixed later 2026-07-03 18:27:49 +03:00
joonas-a 63988f7d6a [Updater][StudyRightElements] new logic to combat corrupted "most recent" snapshots 2026-07-03 18:13:14 +03:00
Aaro Koivukangas b1433ee4a2 [ApiTests] Update tests to match new test data 2026-07-03 17:38:12 +03:00
Aaro Koivukangas f0082e5dd0 [ApiTests] Refactor flaky api test 2026-07-03 16:58:18 +03:00
Aaro Koivukangas f8d2097f24 [ApiTests] Move api tests to api dir 2026-07-03 16:31:48 +03:00
Aaro Koivukangas 0bedc21a57 [Scripts] Add silent testig mode for backend api tests 2026-07-03 16:15:21 +03:00
Aaro Koivukangas 8a119485de [CI] Change ci to use vitest for backend api tests 2026-07-03 16:06:33 +03:00
Aaro Koivukangas 2f94bfd474 [CI] Unify normal and ci compose.yamls to use same ports 2026-07-03 16:01:41 +03:00
Aaro Koivukangas 5082ec9deb [ApiTests] Migrated changelog tests 2026-07-03 15:51:20 +03:00
Aaro Koivukangas 11c75181c3 [ApiTests] Migrated programme course stats tests 2026-07-03 15:51:20 +03:00
Aaro Koivukangas 02935f7da8 [ApiTests] Migrated populationStudyProgramme tests 2026-07-03 15:51:19 +03:00
Aaro Koivukangas 9bb43caa57 [ApiTests] Migrated populationStatisticsByStudentNumbers tests 2026-07-03 15:51:19 +03:00
Aaro Koivukangas 9995e61939 [ApiTests] Migrated populationStatisticsByCourse tests 2026-07-03 15:51:19 +03:00
Aaro Koivukangas 7fbeb34e6d [ApiTests] Migrated populationstatsitics tests 2026-07-03 15:51:18 +03:00
Aaro Koivukangas eda0069e30 [ApiTests] Change to use vitest 2026-07-03 15:51:18 +03:00
joonas-a dcf7ef3e23 [Backend][Graduation times] No longer send grad time arrays to client as they are not needed and pollute the response 2026-07-03 15:42:17 +03:00
joonas-a 55474d5de9 Remove empty file 2026-07-03 15:27:46 +03:00
joonas-a eb843ff43d [E2E][Programme overview] fix tests 2026-07-03 15:02:14 +03:00
joonas-a de32410cc7 [E2E][University] fix tests 2026-07-03 14:18:53 +03:00
Aaro Koivukangas fd8c67384e [CI] Remove watch from logs:ci command, tests hang 2026-07-03 13:48:19 +03:00
Aaro Koivukangas 931b6632f2 [ApiTests] Remove unused db connection 2026-07-03 13:46:30 +03:00
Aaro Koivukangas 5769d7b996 [ApiTests] Add a possibility to execute tests locally 2026-07-03 13:46:30 +03:00
joonas-a f81e8e59da [Scheduler] remove duplicate log entry
The exact same thing is logged two lines in a row
2026-07-03 12:40:05 +03:00
Aaro Koivukangas 85830efce5 [CI] Remove bloat from logging 2026-07-03 12:20:21 +03:00
Aaro Koivukangas 55f48c4b77 [E2E] Add admin privileges to one test 2026-07-03 10:57:50 +03:00
Aaro Koivukangas 119073a87a [E2E] Add e2e test to check if numbers match between Programme courses and Course stats 2026-07-02 19:22:52 +03:00
Aaro Koivukangas 5863cc90be [ApiTests] Add Programme course tests and make numbers match to Course stats 2026-07-02 19:22:51 +03:00
Aaro Koivukangas b794d9d778 [CourseStats] Improve tests + Fix Enrolled no grade calculations 2026-07-02 19:22:51 +03:00
Aaro Koivukangas 8d79a68a81 [ApiTests] By default silence logging inside tests 2026-07-02 19:22:51 +03:00
Aaro Koivukangas 5ca04fea1a [Faculties] Fix type name typo 2026-07-02 19:22:51 +03:00
Aaro Koivukangas 369ff207cb [Dockerignore] Change to toska-standard .dockerignore files 2026-07-02 19:22:50 +03:00
Aaro Koivukangas a863aa798c [CI] Trigger updater workflows on pushes, not only on releases 2026-07-02 19:20:08 +03:00
Aaro KoivukangasandAaro Koivukangas 7c15472c17 [ApiTests] Add more thorough testing for /courseyearlystats 2026-07-02 19:20:07 +03:00
joonas-a 2a9509ed90 [Charts][Graduations] Improve UI 2026-07-02 15:43:24 +03:00
joonas-a 237d47c22e [University][Graduations] add averages 2026-07-02 15:27:05 +03:00
joonas-a c1340daf3d [Faculties][Graduations] add averages 2026-07-02 15:25:15 +03:00
joonas-a f899572eaf [Programme overview][Graduations] add averages to charts 2026-07-02 15:18:02 +03:00
joonas-a c16d18b699 [Degree programme graduations] Add "average" to respose and make typings consistent with faculty/uni views 2026-07-02 13:22:59 +03:00
joonas-a 5f31e03ae1 [Updater][StudyRightElements] Consider phase graduations earlier in the mankeli
Now it does not grant phase 2 elements before phase 1 graduation, and
does not allow modifying elements if there is a graduation already
2026-07-02 12:44:54 +03:00
joonas-a 70120f27bc [Updater][StudyRightElements] fix error when student has many phase 2 educations before phase 1 completion 2026-07-01 13:15:22 +03:00
joonas-a 96889eca28 [Logging] Ignore logs to /updater/jobs endpoint as it is polled very often 2026-07-01 12:53:13 +03:00
joonas-a 0336c27aac [Updater] add logs for studyrightelementmapper errors 2026-07-01 12:40:16 +03:00
Santeri Rajamäki 5e6a320c88 [Frontend] Move PopulationComponent Students Table component tree 2026-06-30 12:12:18 +03:00
Santeri Rajamäki 092aefb177 [Frontend] Move PopulationStudents component tree 2026-06-30 12:12:18 +03:00
Santeri Rajamäki 8ce01807c2 [CoursePopulation] Add fetch skip on invalid mainCodes array 2026-06-30 12:12:18 +03:00
Santeri Rajamäki 8331e462f1 [Frontend] Remove "index" from path subfixes 2026-06-30 12:12:18 +03:00
Santeri Rajamäki 22a672bbe8 [Frontend] Consolidate ProgrammeDist infobox 2026-06-30 12:12:18 +03:00
Santeri Rajamäki d21f383b17 [Frontend] Convert relative paths to absolute \@ paths 2026-06-30 12:12:16 +03:00
Santeri Rajamäki ca826a387b [Frontend] Move ProgrammeDist component 2026-06-30 12:10:34 +03:00
Santeri Rajamäki 17cbaaa024 [Frontend] Move CourseTableModeSelector component 2026-06-30 12:10:34 +03:00
Santeri Rajamäki 9aa8e84dbc [Frontend] Move PopulationCourses component 2026-06-30 12:10:34 +03:00
Santeri Rajamäki f69c96e807 [Frontend] Move AgeStats component 2026-06-30 12:10:34 +03:00
Santeri Rajamäki 6ec7153c6f [Frontend] Move CreditGainStats component 2026-06-30 12:10:34 +03:00
Santeri Rajamäki 98bf2de51f [Frontend] Move CreditAccumulation component 2026-06-30 12:10:34 +03:00
joonas-a 34cb0c83d9 [Update][Studyrights] prevent creating phase 2 elements before if there is no phase 1 graduation 2026-06-29 16:39:13 +03:00
Aaro Koivukangas 0b76807706 [E2E] Test number fix 2026-06-29 14:24:55 +03:00
Aaro Koivukangas e6676044a9 [ProgrammeCourses+CourseStats+ApiTests] Fix calculations and make numbers match between the two pages 2026-06-29 13:52:15 +03:00
625 changed files with 30976 additions and 16651 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"
]
}
+61
View File
@@ -0,0 +1,61 @@
name: 'Build backend'
description: 'Build backend in staging or production version'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
push:
required: false
description: Also push built image to toska registry
default: 'false'
registry_password:
required: false
description: Password to quay.io registry
default: ''
runs:
using: composite
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: |
quay.io/toska/oodikone-backend
tags: |
${{ inputs.environment }}
${{ github.sha }}
- name: Login to quay.io
if: ${{ inputs.push == 'true' }}
uses: docker/login-action@v4
with:
registry: quay.io/toska
username: toska+github
password: ${{ inputs.registry_password }}
- name: 'Build image and export it'
id: build-image-action
uses: docker/build-push-action@v7
with:
context: services
file: services/backend/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
BASE_PATH=${{ case(inputs.environment == 'staging', '/oodikone', '') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
env:
DOCKER_BUILD_SUMMARY: false
@@ -0,0 +1,72 @@
name: 'Build frontend'
description: 'Build frontend in staging or production versions'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
sentry_dns:
required: false
description: 'Sentry url to send error logs to (only production, enforced)'
default: ''
push:
required: false
description: Also push built image to toska registry
default: 'false'
registry_password:
required: false
description: Password to quay.io registry
default: ''
runs:
using: composite
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to quay.io
if: ${{ inputs.push == 'true' }}
uses: docker/login-action@v4
with:
registry: quay.io/toska
username: toska+github
password: ${{ inputs.registry_password }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: |
quay.io/toska/oodikone-frontend
tags: |
${{ inputs.environment }}
${{ github.sha }}
- name: Get current time
id: get_current_time
shell: bash
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- name: 'Build image and export it'
id: build-image-action
uses: docker/build-push-action@v7
with:
context: services
file: services/frontend/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
BASE_PATH=${{ case(inputs.environment == 'staging', '/oodikone', '') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
SENTRY_DSN=${{ case(inputs.environment == 'production', inputs.sentry_dns, '') }}
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
env:
DOCKER_BUILD_SUMMARY: false
@@ -0,0 +1,60 @@
name: 'Build updater scheduler'
description: 'Build updater scheduler in staging or production version'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
push:
required: false
description: Also push built image to toska registry
default: 'false'
registry_password:
required: false
description: Password to quay.io registry
default: ''
runs:
using: composite
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to quay.io
if: ${{ inputs.push == 'true' }}
uses: docker/login-action@v4
with:
registry: quay.io/toska
username: toska+github
password: ${{ inputs.registry_password }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: |
quay.io/toska/updater-scheduler
tags: |
${{ inputs.environment }}
${{ github.sha }}
- name: 'Build image and export it'
id: build-image-action
uses: docker/build-push-action@v7
with:
context: updater/sis-updater-scheduler
file: updater/sis-updater-scheduler/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
env:
DOCKER_BUILD_SUMMARY: false
+60
View File
@@ -0,0 +1,60 @@
name: 'Build updater worker'
description: 'Build updater worker in staging or production version'
inputs:
environment:
required: true
description: 'staging or production'
default: 'production'
release_version:
required: false
description: 'Release version added to production builds'
default: ${{ github.sha }}
push:
required: false
description: Also push built image to toska registry
default: 'false'
registry_password:
required: false
description: Password to quay.io registry
default: ''
runs:
using: composite
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to quay.io
if: ${{ inputs.push == 'true' }}
uses: docker/login-action@v4
with:
registry: quay.io/toska
username: toska+github
password: ${{ inputs.registry_password }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: |
quay.io/toska/updater-worker
tags: |
${{ inputs.environment }}
${{ github.sha }}
- name: 'Build image and export it'
id: build-image-action
uses: docker/build-push-action@v7
with:
context: updater/sis-updater-worker
file: updater/sis-updater-worker/Dockerfile
build-args: |
GIT_SHA=${{ github.sha }}
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
SENTRY_ENVIRONMENT=${{ inputs.environment }}
SENTRY_RELEASE=${{ inputs.release_version }}
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
env:
DOCKER_BUILD_SUMMARY: false
+4 -4
View File
@@ -1,10 +1,10 @@
name: 'Lint code'
description: 'Runs ESLint and Stylelint for frontend and backend'
description: 'Runs Oxlint and Stylelint for frontend and backend'
runs:
using: composite
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24
- name: 'Install dependencies'
@@ -13,6 +13,6 @@ runs:
- name: 'Run Stylelint for CSS files'
shell: bash
run: npm run stylelint "services/frontend/src/**/*.css"
- name: 'Run ESLint for JS and TS files'
- name: 'Run Oxlint for the whole repository'
shell: bash
run: npm run eslint -- services
run: npm run lint
+9
View File
@@ -0,0 +1,9 @@
name: Teardown docker compose (docker-compose.ci.yml)
description: 'Teardown all containers started by docker (docker-compose.ci.yml)'
runs:
using: composite
steps:
- name: 'Teardown docker compose'
shell: bash
run: docker compose --file docker-compose.ci.yml --profile="*" down --remove-orphans
+41
View File
@@ -0,0 +1,41 @@
name: 'Run api tests'
description: 'Runs api tests in backend'
inputs:
username:
required: true
description: 'Toska registry username'
password:
required: true
description: 'Toska registry password'
runs:
using: composite
steps:
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Login to toska docker registry
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ inputs.username }}
password: ${{ inputs.password }}
- name: Start oodikone (without frontend and backend)
shell: bash
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
- name: Install dependencies
shell: bash
run: npm ci --prefix ./services/backend
- name: Run api-tests
shell: bash
run: npm run test:api --prefix ./services/backend
- name: Teardown containers started by compose
if: always()
uses: ./.github/actions/teardown
@@ -0,0 +1,31 @@
name: 'Run component tests'
description: 'Runs frontend component tests'
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 ./services/shared && npm ci --prefix ./services/frontend
- name: Install Playwright Browsers
shell: bash
run: npm exec -- playwright install --with-deps
working-directory: ./services/frontend
- name: Run Playwright tests
shell: bash
run: npm run test:component --prefix ./services/frontend
- uses: actions/upload-artifact@v4
if: |
!cancelled()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
@@ -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,22 +29,16 @@ runs:
shell: bash
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
- name: 'Run cypress'
uses: cypress-io/github-action@v7
with:
browser: chrome
spec: ${{ inputs.spec }}
env:
CYPRESS_baseUrl: http://localhost:1337
- name: 'Upload video if tests fail'
uses: actions/upload-artifact@v7
if: failure()
with:
name: 'Cypress videos - ${{ inputs.test_name }}'
path: ./cypress/videos/*
- name: Run playwright tests (${{ inputs.test_name }}})
shell: bash
run: |
CI='true' npm run playwright ${{ inputs.spec }}
- name: 'Print logs on failure'
shell: bash
if: failure()
run: docker compose --file docker-compose.ci.yml --profile oodikone logs && sleep 15
run: npm run logs:ci && sleep 15
- name: Teardown containers started by compose
if: always()
uses: ./.github/actions/teardown
+41
View File
@@ -0,0 +1,41 @@
name: 'Run service tests'
description: 'Runs service tests in backend'
inputs:
username:
required: true
description: 'Toska registry username'
password:
required: true
description: 'Toska registry password'
runs:
using: composite
steps:
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Login to toska docker registry
uses: docker/login-action@v4
with:
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
username: ${{ inputs.username }}
password: ${{ inputs.password }}
- name: Start oodikone (without frontend and backend)
shell: bash
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
- name: Install dependencies
shell: bash
run: npm ci --prefix ./services/backend
- name: Run service-tests
shell: bash
run: npm run test:services --prefix ./services/backend
- name: Teardown containers started by compose
if: always()
uses: ./.github/actions/teardown
+26
View File
@@ -0,0 +1,26 @@
name: 'Run unit tests'
description: 'Runs unit tests in backend and frontend'
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 ./services/shared && npm ci --prefix ./services/backend && npm ci --prefix ./services/frontend
- name: Run shared unit tests
shell: bash
run: npm run test:unit --prefix ./services/shared
- name: Run frontend unit tests
shell: bash
run: npm run test:unit --prefix ./services/frontend
- name: Run backend unit tests
shell: bash
run: npm run test:unit --prefix ./services/backend
@@ -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
+43 -68
View File
@@ -5,87 +5,59 @@ on:
types: [published]
jobs:
release_frontend:
name: 'Build and release frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: 'Get current time'
id: get_current_time
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- name: Extract release version
id: extract_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build frontend image
id: build-image
uses: redhat-actions/buildah-build@v2
with:
image: oodikone-frontend
tags: production ${{ github.sha }}
context: services
containerfiles: |
services/frontend/Dockerfile
build-args: |
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
SENTRY_RELEASE=${{ steps.extract_version.outputs.VERSION }}
SENTRY_ENVIRONMENT=production
SENTRY_DSN=${{ secrets.OODIKONE_FRONTEND_SENTRY_DSN }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
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@v6
- uses: actions/checkout@v7
- name: Extract release version
id: extract_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build backend image
- name: 'Build backend'
id: build-image
uses: redhat-actions/buildah-build@v2
uses: ./.github/actions/build/backend
with:
image: oodikone-backend
tags: production ${{ github.sha }}
context: services
containerfiles: |
services/backend/Dockerfile
build-args: |
SENTRY_RELEASE=${{ steps.extract_version.outputs.VERSION }}
SENTRY_ENVIRONMENT=production
environment: 'production'
release_version: ${{ steps.extract_version.outputs.VERSION }}
push: true
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
release_frontend:
name: 'Build and release frontend'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Extract release version
id: extract_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: 'Build frontend'
id: build-image
uses: ./.github/actions/build/frontend
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
environment: 'production'
release_version: ${{ steps.extract_version.outputs.VERSION }}
sentry_dns: ${{ secrets.OODIKONE_FRONTEND_SENTRY_DSN }} # Used only in production
push: true
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
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
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Extract release version
id: extract_version
@@ -113,13 +85,14 @@ 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
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Extract release version
id: extract_version
@@ -147,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
@@ -157,9 +131,9 @@ jobs:
if: success()
steps:
- uses: actions/checkout@v6
- 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
@@ -168,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
@@ -178,9 +153,9 @@ jobs:
if: failure()
steps:
- uses: actions/checkout@v6
- 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
+17 -90
View File
@@ -5,99 +5,26 @@ on:
branches:
- master
paths:
- 'cypress/**'
- 'cypress.config.json'
- '.github/actions/**'
- '.github/workflows/pull_request.yaml'
- '.github/workflows/test.yaml'
- 'services/**'
- 'updater/**'
- 'e2e/**'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- '.oxlintrc.json'
- '.oxlint-backend-rules.cjs'
- 'package*.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@v6
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
test_api:
name: 'Test backend api'
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
needs:
- lint_packages
strategy:
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install node-24 for cypress
uses: actions/setup-node@v6
with:
node-version: 24
- 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.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: Start oodikone detached
run: npm run oodikone:ci:setup
- name: Run api-tests
run: npm run oodikone:ci: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
steps:
- uses: actions/checkout@v6
- name: 'Run tests'
uses: ./.github/actions/run-tests
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 }}
+79 -64
View File
@@ -8,12 +8,13 @@ on:
- '.github/actions/**'
- '.github/workflows/staging.yaml'
- '.github/workflows/test.yaml'
- 'cypress/**'
- 'cypress.config.json'
- 'services/**'
- 'updater/sis-updater-worker/src/db/migrations/**'
- 'updater/**'
- 'e2e/**'
- 'docker-compose.ci.yml'
- 'docker-compose.test.yml'
- '.oxlintrc.json'
- '.oxlint-backend-rules.cjs'
- 'package*.json'
jobs:
@@ -22,85 +23,96 @@ jobs:
secrets:
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
release_frontend:
name: 'Build and release frontend'
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- name: Get current time
id: get_current_time
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- name: Build frontend image
id: build-image
uses: redhat-actions/buildah-build@v2
with:
image: oodikone-frontend
tags: staging ${{ github.sha }}
context: services
containerfiles: |
services/frontend/Dockerfile
build-args: |
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
BASE_PATH=/oodikone
STAGING=true
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
s3_access_key: ${{ secrets.S3_ACCESS_KEY }}
s3_secret_key: ${{ secrets.S3_SECRET_KEY }}
release_backend:
name: 'Build and release backend'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs: test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Build backend image
- name: 'Build backend'
id: build-image
uses: redhat-actions/buildah-build@v2
uses: ./.github/actions/build/backend
with:
image: oodikone-backend
tags: staging ${{ github.sha }}
context: services
containerfiles: |
services/backend/Dockerfile
build-args: |
BASE_PATH=/oodikone
GIT_SHA=${{ github.sha }}
STAGING=true
environment: 'staging'
release_version: ${{ github.sha }}
push: true
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
release_frontend:
name: 'Build and release frontend'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs: test
steps:
- uses: actions/checkout@v7
- name: 'Build frontend'
id: build-image
uses: ./.github/actions/build/frontend
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
environment: 'staging'
release_version: ${{ github.sha }}
push: true
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
release_updater_worker:
name: 'Build and release updater worker'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs: test
steps:
- uses: actions/checkout@v7
- name: 'Build worker'
id: build-image
uses: ./.github/actions/build/worker
with:
environment: 'staging'
release_version: ${{ github.sha }}
push: true
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
release_updater_scheduler:
name: 'Build and release updater scheduler'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
needs: test
steps:
- uses: actions/checkout@v7
- name: 'Build scheduler'
id: build-image
uses: ./.github/actions/build/scheduler
with:
environment: 'staging'
release_version: ${{ github.sha }}
push: true
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
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
if: success()
needs:
- release_frontend
- release_backend
- release_updater_worker
- release_updater_scheduler
steps:
- uses: actions/checkout@v6
- 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
@@ -109,16 +121,19 @@ 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
if: failure()
needs:
- release_frontend
- release_backend
- release_updater_worker
- release_updater_scheduler
steps:
- uses: actions/checkout@v6
- 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
+289 -327
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,126 +15,66 @@ 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: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Lint backend, frontend and shared
uses: ./.github/actions/lint
# === Build database images ===
build_backend:
name: 'Build backend'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: 'Build image and export it'
uses: docker/build-push-action@v7
- uses: actions/checkout@v7
- name: Build backend
uses: ./.github/actions/build/backend
with:
build-args: |
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=staging
context: services
file: services/backend/Dockerfile
environment: 'staging'
release_version: ${{ github.sha }}
push: false
tags: oodikone-backend:staging
outputs: type=docker,dest=/tmp/backend.tar
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v7
with:
if-no-files-found: error
name: backend-image
path: /tmp/backend.tar
build_frontend:
name: 'Build frontend'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Get current time
id: get_current_time
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
- name: Build frontend image
uses: docker/build-push-action@v7
- uses: actions/checkout@v7
- name: Build frontend
uses: ./.github/actions/build/frontend
with:
build-args: |
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
BASE_PATH=/oodikone
STAGING=true
context: services
file: services/frontend/Dockerfile
environment: 'staging'
release_version: ${{ github.sha }}
push: false
tags: oodikone-frontend:staging
outputs: type=docker,dest=/tmp/frontend.tar
env:
DOCKER_BUILD_SUMMARY: false
# - name: 'Upload image for other jobs'
# uses: actions/upload-artifact@v7
# with:
# if-no-files-found: error
# name: frontend-image
# path: /tmp/frontend.tar
pull_docker_images:
name: 'Pull all needed docker images and cache them'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- 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: 'Pull required images'
run: docker compose --file docker-compose.ci.yml --profile oodikone --profile updater pull --ignore-buildable --quiet
- name: 'Save Docker images for downstream jobs'
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/sis-db \
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db \
redis:7.2.6 \
postgres:15.10 \
"quay.io/toska/jami:production" \
nginx:latest \
| zstd -T0 -3 -o /tmp/docker-images.tar.zst
- name: 'Upload Docker images artifact'
uses: actions/upload-artifact@v7
with:
name: docker-images
path: /tmp/docker-images.tar.zst
retention-days: 1
# === Update databases ===
check_migration_files:
name: Check if new migrations have been added
runs-on: ubuntu-latest
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 }}
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Check if new migrations have been added
id: check_migrations
uses: dorny/paths-filter@v3
uses: dorny/paths-filter@v4
with:
filters: |
kone_db_migrations:
@@ -143,14 +89,22 @@ 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: ubuntu-latest
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.sis_db_migrations == 'true' &&
inputs.skip_db_update != 'true'
needs:
- pull_docker_images
- check_migration_files
- build_backend
- build_frontend
- lint_packages
- test_unit
- test_updater_unit
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
with:
@@ -158,48 +112,38 @@ jobs:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- 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()
uses: ./.github/actions/teardown
build_and_push_anon_kone_db:
name: 'Build anon kone-db and push to toska hub if new migrations have been added'
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.kone_db_migrations == 'true' &&
inputs.skip_db_update != 'true'
needs:
- pull_docker_images
- check_migration_files
- build_backend
- build_frontend
- check_migration_files
- lint_packages
- test_unit
- test_updater_unit
steps:
- uses: actions/checkout@v6
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Download backend image'
uses: actions/download-artifact@v8
with:
name: backend-image
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Load built backend image'
run: docker load --input backend.tar
- uses: actions/checkout@v7
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
@@ -209,44 +153,37 @@ 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 backend
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()
uses: ./.github/actions/teardown
build_and_push_anon_user_db:
name: 'Build anon user-db and push to toska hub if new migrations have been added'
runs-on: ubuntu-latest
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure() &&
needs.check_migration_files.outputs.user_db_migrations == 'true' &&
inputs.skip_db_update != 'true'
needs:
- pull_docker_images
- check_migration_files
- build_backend
- build_frontend
- check_migration_files
- lint_packages
- test_unit
- test_updater_unit
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: check_migrations
with:
filters: |
migrations:
- 'services/backend/src/database/migrations_user/**'
- '.github/workflows/test.yaml'
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Download backend image'
uses: actions/download-artifact@v8
with:
name: backend-image
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Load built backend image'
run: docker load --input backend.tar
- uses: actions/checkout@v7
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
@@ -256,198 +193,244 @@ 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 backend
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()
uses: ./.github/actions/teardown
# === Update sis-db with updater ===
check_updater_changed:
name: 'Check if updater has been updated'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
outputs:
has_updater_changed: ${{ steps.check_updater_changed.outputs.has_updater_changed }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: check_updater_changed
with:
filters: |
has_updater_changed:
- 'updater/**'
- '.github/workflows/test.yaml'
- '.github/workflows/staging.yaml'
- 'docker-compose.ci.yml'
run_updater_updates:
name: 'Run updater tests and updates'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure() &&
(needs.check_updater_changed.outputs.has_updater_changed == 'true' ||
needs.check_migration_files.outputs.sis_db_migrations == 'true')
needs:
- check_migration_files
- check_updater_changed
- build_and_push_anon_sis_db
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: Start worker containers
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-worker --scale sis-updater-worker=3 --quiet-pull --detach --wait
- name: Run hourly jobs
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: hourly
EXIT_AFTER_IMMEDIATES: yes
- name: Run weekly jobs
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: weekly
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() && 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()
run: docker compose --file docker-compose.ci.yml --profile updater logs
- name: Teardown containers started by compose
if: always()
uses: ./.github/actions/teardown
# === Backend api-tests ===
test_api:
name: 'Run backend api tests'
runs-on: ubuntu-latest
if: always() && !cancelled() && needs.lint_packages.result == 'success'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
needs:
- pull_docker_images
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- run_updater_updates
- build_and_push_anon_user_db
- lint_packages
strategy:
fail-fast: false
- build_and_push_anon_kone_db
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install node-24
uses: actions/setup-node@v6
- uses: actions/checkout@v7
- name: Run api tests
uses: ./.github/actions/test/api
with:
node-version: 24
username: ${{ secrets.username }}
password: ${{ secrets.password }}
#
# === Backend service-tests ===
test_services:
name: 'Run backend service-tests'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
needs:
- run_updater_updates
- build_and_push_anon_user_db
- build_and_push_anon_kone_db
- name: 'Download Docker images'
uses: actions/download-artifact@v8
steps:
- uses: actions/checkout@v7
- name: Run service-tests
uses: ./.github/actions/test/services
with:
name: docker-images
path: /tmp
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- 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: Start oodikone (without frontend and backend)
run: npm run oodikone:ci:setup -- --quiet-pull
- name: Run api-tests
run: npm run oodikone:ci:api
# === Frontend unit testing ===
test_unit_frontend:
name: 'Run frontend unit tests'
runs-on: ubuntu-latest
if: always() && !cancelled() && needs.lint_packages.result == 'success'
needs:
- pull_docker_images
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- build_and_push_anon_user_db
- lint_packages
# === Unit testing ===
test_unit:
name: 'Run unit tests'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Run unit tests (frontend and backend)
uses: ./.github/actions/test/unit
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
# === Updater unit testing ===
test_updater_unit:
name: 'Run updater unit tests'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
- name: Install dependencies
run: npm ci
working-directory: ./services/frontend
- name: Run unit tests
run: npm run test:unit
working-directory: ./services/frontend
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: ubuntu-latest
if: always() && !cancelled() && needs.lint_packages.result == 'success'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
needs:
- pull_docker_images
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- run_updater_updates
- build_and_push_anon_user_db
- lint_packages
- build_and_push_anon_kone_db
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install node-24
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: npm ci --prefix shared && npm ci --prefix frontend
working-directory: ./services
- name: Install Playwright Browsers
run: npm exec -- playwright install --with-deps
working-directory: ./services/frontend
- name: Run Playwright tests
run: npm run test:component
working-directory: ./services/frontend
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/checkout@v7
- name: Run frontend component tests
uses: ./.github/actions/test/component
# === End-to-end tests ===
test_end_to_end:
runs-on: ubuntu-latest
if: always() && !cancelled() && needs.lint_packages.result == 'success'
test_end_to_end_playwright:
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
needs:
- pull_docker_images
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- run_updater_updates
- build_and_push_anon_user_db
- lint_packages
- build_and_push_anon_kone_db
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: '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@v6
uses: actions/checkout@v7
- name: Install node-24 for cypress
uses: actions/setup-node@v6
- name: Install node-24 for playwright
uses: actions/setup-node@v7
with:
node-version: 24
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Download backend image'
uses: actions/download-artifact@v8
with:
name: backend-image
# - name: 'Download frontend image'
# uses: actions/download-artifact@v8
# with:
# name: frontend-image
- name: Install dependencies
run: |
npm ci
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Load built backend image'
run: docker load --input backend.tar
# - name: 'Load built frontend image'
# run: docker load --input frontend.tar
- name: Install Playwright Browsers
run: npx playwright install firefox --with-deps
- name: 'Run tests'
uses: ./.github/actions/run-tests
- name: Run tests
uses: ./.github/actions/test/e2e-playwright
with:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
@@ -457,17 +440,17 @@ jobs:
# === Other tests ===
test_service_exit_statuses:
name: 'Check services run correctly'
runs-on: ubuntu-latest
if: always() && !cancelled() && needs.lint_packages.result == 'success'
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
if: |
!cancelled() && !failure()
needs:
- pull_docker_images
- build_and_push_anon_sis_db
- build_and_push_anon_kone_db
- run_updater_updates
- build_and_push_anon_user_db
- lint_packages
- build_and_push_anon_kone_db
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: 'Login to toska docker registry'
uses: docker/login-action@v4
with:
@@ -475,29 +458,8 @@ jobs:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: 'Download Docker images'
uses: actions/download-artifact@v8
with:
name: docker-images
path: /tmp
- name: 'Download backend image'
uses: actions/download-artifact@v8
with:
name: backend-image
# - name: 'Download frontend image'
# uses: actions/download-artifact@v8
# with:
# name: frontend-image
- name: 'Load Docker images'
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
- name: 'Load built backend image'
run: docker load --input backend.tar
# - name: 'Load built frontend image'
# run: docker load --input frontend.tar
- name: 'Run services, build rest of the needed images'
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull --wait
- name: 'Check that exit statuses are not errors'
run: |
@@ -509,4 +471,4 @@ jobs:
- name: 'Print logs on failure'
if: failure()
run: docker compose --file docker-compose.ci.yml --profile oodikone logs && sleep 15
run: npm run logs:ci && sleep 15
+62 -62
View File
@@ -5,32 +5,40 @@ on:
types: [published]
paths:
- '.github/workflows/updater.yaml'
- 'updater/sis-updater-worker/**'
- 'updater/sis-updater-scheduler/**'
- 'updater/**'
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@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- name: Install safe-chain
run: set -o pipefail && curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- name: 'Install dependencies'
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
- name: 'Run linter'
run: npm run eslint -- updater
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@v6
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- name: 'Build the image'
uses: docker/build-push-action@v7
@@ -41,17 +49,21 @@ jobs:
tags: toska/sis-updater-worker:latest
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v7
with:
name: updater-worker-image
path: /tmp/worker.tar
retention-days: 7
build_scheduler:
name: 'Build Updater scheduler'
runs-on: ubuntu-latest
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- name: 'Build the image'
uses: docker/build-push-action@v7
@@ -62,113 +74,101 @@ jobs:
tags: toska/sis-updater-scheduler:latest
env:
DOCKER_BUILD_SUMMARY: false
- name: 'Upload image for other jobs'
uses: actions/upload-artifact@v7
with:
name: updater-scheduler-image
path: /tmp/scheduler.tar
retention-days: 7
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
steps:
- uses: actions/checkout@v6
- 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.TOSKAREGISTRY_USERNAME }}
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
- name: 'Download built images'
uses: actions/download-artifact@v8
with:
pattern: 'updater-{worker,scheduler}-image'
merge-multiple: true
- name: 'Load worker image'
run: docker load --input worker.tar
- name: 'Load scheduler image'
run: docker load --input scheduler.tar
- name: 'Start worker'
run: docker compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker
run: docker compose --file docker-compose.ci.yml up --detach --quiet-pull --no-recreate --wait sis-updater-worker
- name: 'Run hourly jobs'
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: hourly
EXIT_AFTER_IMMEDIATES: yes
- name: 'Run weekly jobs'
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: weekly
EXIT_AFTER_IMMEDIATES: yes
- name: 'Run prepurge'
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: prepurge
EXIT_AFTER_IMMEDIATES: yes
- name: 'Run purge'
run: docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
env:
SCHEDULE_IMMEDIATE: purge
EXIT_AFTER_IMMEDIATES: yes
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@v6
- uses: actions/checkout@v7
- name: Build worker image
- name: 'Build updater worker'
id: build-image
uses: redhat-actions/buildah-build@v2
uses: ./.github/actions/build/worker
with:
image: updater-worker
tags: staging production ${{ github.sha }}
context: updater/sis-updater-worker
containerfiles: |
updater/sis-updater-worker/Dockerfile
build-args: |
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=production
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
environment: 'production'
release_version: ${{ github.sha }}
push: true
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
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@v6
- uses: actions/checkout@v7
- name: Build scheduler image
- name: 'Build udpater scheduler'
id: build-image
uses: redhat-actions/buildah-build@v2
uses: ./.github/actions/build/scheduler
with:
image: updater-scheduler
tags: staging production ${{ github.sha }}
context: updater/sis-updater-scheduler
containerfiles: |
updater/sis-updater-scheduler/Dockerfile
build-args: |
SENTRY_RELEASE=${{ github.sha }}
SENTRY_ENVIRONMENT=production
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
environment: 'production'
release_version: ${{ github.sha }}
push: true
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
+5 -1
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
@@ -56,7 +58,7 @@ cypress/videos/
coverage/
instrumented/
# Pycharm
# Pycharm
.idea
# VS Code
@@ -68,3 +70,5 @@ instrumented/
# Target of shared symlink
services/**/src/shared
!services/backend/.env.test
+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
+9 -7
View File
@@ -5,15 +5,17 @@ const dockerCmdBase = `docker run --rm --volume ${cwd}:/oodikone --workdir /oodi
const relativeFilePaths = files => [...files.map(file => relative(cwd, file))].join(' ')
export default {
// INFO: oxlint --type-check is equivalent to running tsc --noEmit
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
`eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
'services/backend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
'services/frontend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `prettier --write ${files.join(' ')}`,
`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(' ')} `,
'*.css': files => `stylelint --fix ${files.join(' ')}`,
Dockerfile: files =>
`${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.7.2 ${relativeFilePaths(files)} -x`,
`${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))) {
@@ -25,6 +27,6 @@ export default {
return ''
}
return files.map(file => `docker-compose ${composeFiles(file)} config --quiet`)
return files.map(file => `docker compose ${composeFiles(file)} config --quiet`)
},
}
+4 -1
View File
@@ -1,4 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"arrowParens": "avoid",
"singleQuote": true,
@@ -7,5 +8,7 @@
"useTabs": false,
"jsxSingleQuote": false,
"trailingComma": "es5",
"endOfLine": "lf"
"endOfLine": "lf",
"sortPackageJson": false,
"ignorePatterns": []
}
+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' })
}
},
}
},
},
},
}
+401
View File
@@ -0,0 +1,401 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["import", "typescript"],
"jsPlugins": ["./.oxlint-backend-rules.cjs"],
"categories": {
"correctness": "off"
},
"options": {
"typeAware": true,
"typeCheck": true
},
"env": {
"builtin": true,
"es2021": true,
"browser": true,
"node": true
},
"settings": {
"import-x/extensions": [".ts", ".tsx", ".cts", ".mts", ".js", ".jsx", ".cjs", ".mjs"],
"import-x/external-module-folders": ["node_modules", "node_modules/@types"],
"import-x/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx", ".cts", ".mts"]
},
"import-x/resolver": {
"typescript": true
},
"import-x/resolver-next": [
{
"interfaceVersion": 3,
"name": "eslint-import-resolver-typescript"
},
{
"interfaceVersion": 3,
"name": "eslint-plugin-import-x:node"
}
]
},
"ignorePatterns": [
"eslint.config.mjs",
".oxlint-backend-rules.cjs",
"**/build/**",
"**/dist/**",
"**/node_modules/**",
"**/instrumented/**",
"**/coverage/**",
"nyc-config.cjs",
".lintstagedrc.mjs",
"playwright/**/*.js",
"playwright.config.ts"
],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-undef": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrors": "none",
"ignoreRestSiblings": true,
"varsIgnorePattern": "^_"
}
],
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"no-empty-function": "error",
"class-methods-use-this": "error",
"no-alert": "error",
"no-await-in-loop": "error",
"no-console": "error",
"no-param-reassign": "error",
"no-promise-executor-return": "error",
"no-return-assign": "error",
"object-shorthand": ["error", "always"],
"prefer-const": "error",
"no-restricted-imports": [
"error",
{
"patterns": [
{
"regex": "^@mui/[^/]+$",
"message": "Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds."
},
{
"regex": "^src/.*",
"message": "Root imports will not resolve in production build (instead silently crash)."
}
]
}
],
"import/namespace": "error",
"import/default": "error",
"import/export": "error",
"import/no-named-as-default": "warn",
"import/no-named-as-default-member": "warn",
"import/no-duplicates": "warn",
"import/no-commonjs": "error",
"import/no-default-export": "error",
"typescript/await-thenable": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-array-delete": "error",
"typescript/no-base-to-string": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-duplicate-type-constituents": "error",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-floating-promises": "error",
"typescript/no-for-in-array": "error",
"typescript/no-implied-eval": "error",
"typescript/no-misused-new": "error",
"typescript/no-misused-promises": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-redundant-type-constituents": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-enum-comparison": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-unsafe-unary-minus": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/only-throw-error": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/prefer-promise-reject-errors": "error",
"typescript/require-await": "error",
"typescript/restrict-plus-operands": "error",
"typescript/triple-slash-reference": "error",
"typescript/unbound-method": "error",
"typescript/adjacent-overload-signatures": "error",
"typescript/ban-tslint-comment": "error",
"typescript/class-literal-property-style": "error",
"typescript/consistent-generic-constructors": "error",
"typescript/consistent-indexed-object-style": "error",
"typescript/consistent-type-assertions": "error",
"typescript/dot-notation": "error",
"typescript/no-confusing-non-null-assertion": "error",
"typescript/no-inferrable-types": "error",
"typescript/non-nullable-type-assertion-style": "error",
"typescript/prefer-find": "error",
"typescript/prefer-for-of": "error",
"typescript/prefer-function-type": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-nullish-coalescing": "warn",
"typescript/prefer-optional-chain": "error",
"typescript/prefer-regexp-exec": "error",
"typescript/prefer-string-starts-ends-with": "error",
"typescript/consistent-return": "error"
},
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-undef": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-rest-params": "error",
"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": {
"typescript/no-misused-promises": "off",
"typescript/consistent-return": "off"
}
},
{
"files": ["{updater,services}/**/*.{js,cjs}"],
"rules": {
"typescript/await-thenable": "off",
"typescript/consistent-return": "off",
"typescript/consistent-type-exports": "off",
"typescript/dot-notation": "off",
"typescript/no-array-delete": "off",
"typescript/no-base-to-string": "off",
"typescript/no-confusing-void-expression": "off",
"typescript/no-deprecated": "off",
"typescript/no-duplicate-type-constituents": "off",
"typescript/no-floating-promises": "off",
"typescript/no-for-in-array": "off",
"typescript/no-implied-eval": "off",
"typescript/no-meaningless-void-operator": "off",
"typescript/no-misused-promises": "off",
"typescript/no-misused-spread": "off",
"typescript/no-mixed-enums": "off",
"typescript/no-redundant-type-constituents": "off",
"typescript/no-unnecessary-boolean-literal-compare": "off",
"typescript/no-unnecessary-condition": "off",
"typescript/no-unnecessary-qualifier": "off",
"typescript/no-unnecessary-template-expression": "off",
"typescript/no-unnecessary-type-arguments": "off",
"typescript/no-unnecessary-type-assertion": "off",
"typescript/no-unnecessary-type-conversion": "off",
"typescript/no-unnecessary-type-parameters": "off",
"typescript/no-unsafe-enum-comparison": "off",
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-unsafe-unary-minus": "off",
"typescript/non-nullable-type-assertion-style": "off",
"typescript/only-throw-error": "off",
"typescript/prefer-find": "off",
"typescript/prefer-includes": "off",
"typescript/prefer-nullish-coalescing": "off",
"typescript/prefer-optional-chain": "off",
"typescript/prefer-promise-reject-errors": "off",
"typescript/prefer-readonly": "off",
"typescript/prefer-readonly-parameter-types": "off",
"typescript/prefer-reduce-type-parameter": "off",
"typescript/prefer-regexp-exec": "off",
"typescript/prefer-return-this-type": "off",
"typescript/prefer-string-starts-ends-with": "off",
"typescript/promise-function-async": "off",
"typescript/related-getter-setter-pairs": "off",
"typescript/require-array-sort-compare": "off",
"typescript/require-await": "off",
"typescript/restrict-plus-operands": "off",
"typescript/return-await": "off",
"typescript/strict-boolean-expressions": "off",
"typescript/switch-exhaustiveness-check": "off",
"typescript/unbound-method": "off",
"typescript/use-unknown-in-catch-callback-variable": "off"
}
},
{
"files": ["services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}"],
"rules": {
"react/react-compiler": "warn",
"react/display-name": "error",
"react/jsx-key": "error",
"react/jsx-no-comment-textnodes": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "off",
"react/no-unknown-property": "error",
"react/no-unsafe": "off",
"react/react-in-jsx-scope": "off",
"react/require-render-return": "error",
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "warn",
"react/no-array-index-key": "error",
"react/no-unstable-nested-components": "warn",
"react/no-this-in-sfc": "error",
"react/jsx-boolean-value": ["error", "never"],
"react/jsx-filename-extension": [
"error",
{
"allow": "as-needed",
"extensions": [".jsx", ".tsx"]
}
],
"react/jsx-no-useless-fragment": "error"
},
"plugins": ["react"]
},
{
"files": ["services/backend/tests/**/*.ts"],
"rules": {
"no-console": "off"
}
},
{
"files": [
"services/backend/**/*.js",
"updater/sis-updater-worker/src/db/migrations/*.cjs",
"updater/sis-updater-scheduler/**/*.js"
],
"rules": {
"import/no-commonjs": "off",
"typescript/no-require-imports": "off"
}
},
{
"files": ["updater/sis-updater-worker/src/db/migrations/*.cjs", "services/backend/src/database/**/*.js"],
"rules": {
"no-empty-function": "off",
"typescript/require-await": "off"
}
},
{
"files": ["services/backend/**/*.{js,ts}", "updater/**/*.js"],
"rules": {
"no-await-in-loop": "off",
"no-promise-executor-return": "off",
"import/no-default-export": "off"
}
},
{
"files": ["e2e/**/*.ts"],
"rules": {
"no-await-in-loop": "off"
}
},
{
"files": [
"services/frontend/tests/**/*.{ts,tsx}",
"services/frontend/playwright/**/*.{ts,tsx}",
"services/frontend/playwright-ct.config.ts"
],
"rules": {
"react/jsx-filename-extension": "off",
"import/no-default-export": "off"
},
"plugins": ["react"]
}
]
}
-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.
+29 -20
View File
@@ -56,6 +56,10 @@ What different CLI options do:
- Runs `docker system prune -a && docker volume prune -a`
- This will remove all your Docker data, also related to any other applications than Oodikone!
- Sometimes necessary after strange errors due to caching
6. Clear node_nodules
- Deletes "nodemod" docker volumes and all local node_modules from each service
- Useful when resolving permission/installation/library issues without the need to reset all data
- Runs `npm ci` for every service locally
## ⌨️ Development
@@ -123,6 +127,8 @@ Some useful commands are defined in `package.json` and can be run with `npm run
- `npm run oodikone`: starts Oodikone with anonymized data
- `npm run oodikone:real`: starts Oodikone with real data
- `npm run oodikone:test`: starts Oodikone with non-persistent database (wipes after every reset)
- `npm run oodikone:ci`: starts Oodikone in CI mode
- `npm run updater`: starts updater with anonymized data
- `npm run updater:real`: starts updater with real data
- `npm run both`: starts Oodikone and updater with anonymized data
@@ -131,8 +137,9 @@ Some useful commands are defined in `package.json` and can be run with `npm run
- `npm run testupdater`: runs tests for updater
- `npm run flushredis`: clears Redis, which forces big calculations to be redone in e.g. studyprogramme overview and faculty views
- `npm run install:local`: installs `node_modules` for each package (backend, frontend, sis-updater-scheduler and sis-updater-worker) locally
- `npm run install:docker`: installs `node_modules` for each package inside containers
- `npm run install:both`: runs both `npm run install:local` and `npm run install:docker`
- `npm run ci:local`: same as above, but sticks to the package versions pinned in package-lock
- `npm run ci:docker`: installs `node_modules` for each package inside containers
- `npm run ci:both`: runs both `npm run ci:local` and `npm run ci:docker`
Once you have ran setup for Oodikone , you can just execute the first one (`npm run oodikone`). After starting and waiting for a while for containers to compile, Oodikone can be accessed at [http://localhost:3000](http://localhost:3000/) and Adminer (database investigation tool) at [http://localhost:5050](http://localhost:5050/). Adminer requires you to login with username `postgres` and with any password you choose (for example `p`).
@@ -168,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
@@ -189,9 +196,13 @@ Continuous integration (CI) works with Github actions and is defined in workflow
## ❓FAQ
### Modules are missing after updating package.json
### How to install a new package
You should always install the dependencies **inside** the container to have the application **inside** the container access them. Module might be missing for example when someone else installs a new library and you only pull the changes in package.json. Use `docker exec <service> npm ci` to install modules inside the container or `npm run install:docker` to install modules inside all packages (backend, frontend, sis-updater-scheduler and sis-updater-worker).
Install the package locally to the relevant service with `npm install` followed by the script `npm run ci:docker`. The application running inside container uses your host-mounted package-lock; therefore when making any changes to packages do them first locally. Avoid running `npm install` inside docker containers. Our docker images run as non-root, and cannot write on the host mounted package-lock files (without sudo'ing and changing back the permissions later). The CLI has an option to clear all node_modules should the need arise.
### Modules are missing after an update to package.json
Module might be missing for example when someone else installs a new library and you pull the changes to the package files, without installing the acual packages. To sync the node_modules with the package files run `npm run ci:both`.
### Study guidance groups don't work on my machine
@@ -205,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"
+28 -9
View File
@@ -1,6 +1,13 @@
x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
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
@@ -10,6 +17,21 @@ services:
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
user-db:
<<: *defaults
container_name: oodikone-user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
sis-db:
<<: *defaults
@@ -20,6 +42,9 @@ services:
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
shm_size: 2gb
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
sis-importer-db:
<<: *defaults
@@ -29,12 +54,6 @@ services:
POSTGRES_DB: sis-importer-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
user-db:
<<: *defaults
container_name: oodikone-user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: postgres:15.10
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
<<: *healthcheck-defaults
+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
+6 -3
View File
@@ -49,10 +49,11 @@ set_custom_select_prompt() {
options=(
"Set up Oodikone from scratch"
"Reset all real data"
"Reset all data"
"Reset single database"
"Restore data from dumps"
"Docker system prune"
"Nuke local node_modules"
"Quit"
)
}
@@ -68,8 +69,8 @@ while true; do
case $opt in
"Set up Oodikone from scratch")
set_up_oodikone_from_scratch;;
"Reset all real data")
reset_all_real_data;;
"Reset all data")
reset_all_data;;
"Reset single database")
reset_single_database
set_custom_select_prompt # Set prompt back to initial values
@@ -78,6 +79,8 @@ while true; do
restore_data_from_dumps;;
"Docker system prune")
docker_prune;;
"Nuke local node_modules")
nuke_node_modules;;
"Quit")
break 2;;
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
-23
View File
@@ -1,23 +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',
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}',
},
video: true,
})
-179
View File
@@ -1,179 +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(', '))
})
it('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])
}
)
})
})
})
-918
View File
@@ -1,918 +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()
})
// 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, Transferred Away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
[2023, 8, 8, 26, 0, 0],
[2022, 25, 26, 48, 1, 4],
[2021, 29, 32, 47, 0, 2],
[2020, 26, 27, 12, 1, 3],
[2019, 28, 34, 1, 0, 1],
[2018, 40, 45, 0, 0, 0],
[2017, 41, 47, 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, 48, 13],
[2021, 47, 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, 44],
[2021, 29, 32, 46],
[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, 44, 12],
[2021, 46, 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, Transferred away, Transferred to]
...years.map(year => [year, 0, 0, 0, 0, 0]),
['2023 - 2024', 8, 8, 4, 0, 0],
['2022 - 2023', 25, 26, 36, 0, 0],
['2021 - 2022', 29, 32, 57, 1, 6],
['2020 - 2021', 26, 27, 30, 0, 3],
['2019 - 2020', 28, 34, 7, 1, 1],
['2018 - 2019', 40, 45, 0, 0, 0],
['2017 - 2018', 41, 47, 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('unset-breakdown-bar-chart')
cy.cs('graduation-time-toggle').click()
cy.cs('unset-median-bar-chart').within(() => {
cy.contains('48 graduated').should('be.visible')
cy.contains('48 graduated').hover()
})
cy.get('.grad-vals').contains('48 students graduated in year 2022').should('be.visible')
cy.contains('median study time: 6.5 semesters')
cy.contains('24 graduated on time')
cy.contains('12 graduated max year overtime')
cy.contains('12 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 two 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('unset-breakdown-bar-chart')
cy.cs('graduation-time-toggle').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('median 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('median study time: 12 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')
})
it('Average graduation times section', () => {
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, Men, Women, Other/Unknown, Finland, Other]
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 3],
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 35, 2],
['2018 - 2019', 45, 40, 0, 0, 6, 39, 26, 19, 0, 44, 1],
['2017 - 2018', 47, 41, 0, 0, 5, 42, 31, 16, 0, 47, 0],
['Total', 229, 197, 0, 0, 95, 134, 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', 38, 8, 4, 9, 8, 4, 0, 0, 5],
['2020 - 2021', 30, 2, 1, 2, 5, 0, 0, 1, 19],
['2019 - 2020', 35, 1, 0, 2, 1, 0, 0, 4, 27],
['2018 - 2019', 45, 0, 1, 1, 2, 0, 0, 2, 39],
['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, Men, Women, Other/Unknown, Finland, Other]
['2022 - 2023', 3, 3, 0, 0, 1, 2, 2, 1, 0, 3, 0],
['2021 - 2022', 5, 1, 0, 0, 1, 4, 3, 2, 0, 5, 0],
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 17, 1],
['2018 - 2019', 23, 21, 0, 0, 2, 21, 11, 12, 0, 23, 0],
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
['Total', 89, 73, 0, 0, 6, 83, 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('273')
})
})
})
})
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', 0, 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, 33, 757, 15, 40, 6, 48, 1, 24, 1059, 48, 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([63, 2, 0, 3, 13, 0, 1, 1, 2, 0, 0, 30, 10, 1], 15, 'faculties')
})
it('Faculties tab semester selector changes numbers', () => {
chooseSemester('Syksy 2020', 'from')
checkNumbers([1184, 28, 6, 27, 497, 4, 10, 2, 33, 0, 9, 535, 33, 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')
})
})
})
-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')
})
})
})
-296
View File
@@ -1,296 +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('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
cy.get('td').eq(0).click()
})
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)').should(
'have.css',
'background-color',
'rgb(232, 244, 255)'
)
})
it('When a study plan is selected, the time frame of the credit graph is updated', () => {
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
cy.visit('/students/550789')
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
cy.get('td').eq(0).click()
})
})
it("If there's a study plan corresponding to the degree programme, completed credits are displayed", () => {
cy.visit('/students/550789')
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
cy.get('td')
.eq(-1)
.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('table tbody tr', 'Oikeusnotaarin koulutusohjelma (01.08.2020–31.07.2027)').within(() => {
cy.get('td').eq(-1).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(1).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()
})
})
-90
View File
@@ -1,90 +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', 'master', 'doctor'] // ? Bachelor + master seems to be missing in test data
const checkAverageGraduationTimesBreakdownBarCharts = () => {
graduationTimesLevels.forEach(level => {
cy.cs(`${level}-breakdown-bar-chart`)
})
}
const checkAverageGraduationTimesMedianBarCharts = () => {
graduationTimesLevels.forEach(level => {
cy.cs(`${level}-median-bar-chart`)
})
}
describe('University view', () => {
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()
})
it('has all the correct median time bar charts', () => {
checkAverageGraduationTimesBreakdownBarCharts()
})
it("'Breakdown/Median times' toggle works", () => {
cy.cs('graduation-time-toggle').click()
checkAverageGraduationTimesMedianBarCharts()
})
it('info boxes contain correct information', () => {
cy.cs('average-graduation-times-info-box-button').click()
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset valmistumisajat')
})
})
})
-91
View File
@@ -1,91 +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')
})
describe('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',
}
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({ hostname: 'localhost', url: '**', method: '*' }, 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
+133 -56
View File
@@ -3,15 +3,43 @@ name: oodikone
x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
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
image: oodikone-backend:staging
build:
context: services
dockerfile: backend/Dockerfile
depends_on:
- kone-db
- redis
- sis-db
- user-db
kone-db:
condition: service_healthy
redis:
condition: service_healthy
sis-db:
condition: service_healthy
user-db:
condition: service_healthy
environment:
CI: 'true'
DB_URL_USER: postgres://postgres@user-db:5432/user-db
@@ -24,40 +52,68 @@ services:
IMPORTER_DB_API_URL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
JAMI_URL: 'http://jami:3003/'
CRYPT_KEY: 'e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
build:
context: services
dockerfile: backend/Dockerfile
image: oodikone-backend:staging
ports:
- 8080:8080
profiles:
- oodikone
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8080/health'] # In CI/prod the routing happens at a different level, hence no /api/ needed
<<: *healthcheck-defaults
frontend:
<<: *defaults
container_name: oodikone-frontend
image: oodikone-frontend:staging
build:
context: services
dockerfile: frontend/Dockerfile
image: oodikone-frontend:staging
depends_on:
backend:
condition: service_healthy
ports:
- 5000:5000
profiles:
- oodikone
kone-db:
<<: *defaults
container_name: oodikone-kone-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db
environment:
PGDATA: /data
POSTGRES_DB: kone-db
POSTGRES_HOST_AUTH_METHOD: trust
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db
ports:
- 4321:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
user-db:
<<: *defaults
container_name: oodikone-user-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 4320:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
redis:
<<: *defaults
container_name: oodikone-redis
image: redis:7.2.6
command: >
sh -c 'redis-server --appendonly yes && redis-cli flushall'
image: redis:7.2.6
ports:
- 6379:6379
healthcheck:
test: ['CMD', 'redis-cli', 'ping', '|', 'grep', 'PONG']
<<: *healthcheck-defaults
sis-db:
<<: *defaults
@@ -68,24 +124,35 @@ services:
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
shm_size: 2gb
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
user-db:
sis-importer-db:
<<: *defaults
container_name: oodikone-user-db
environment:
PGDATA: /data
POSTGRES_DB: user-db
POSTGRES_HOST_AUTH_METHOD: trust
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
container_name: oodikone-sis-importer-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
profiles:
- updater
ports:
- 12346:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
jami:
<<: *defaults
container_name: oodikone-jami
image: quay.io/toska/jami:production
depends_on:
jami-db:
condition: service_healthy
environment:
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=dev
- NODE_ENV=test # previously was development which borks CI
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
ports:
- 3003:3003
@@ -93,63 +160,44 @@ 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', '-h', '127.0.0.1']
<<: *healthcheck-defaults
# This is still needed to make backend calls correctly route from localhost:5000/api to localhost:8080
nginx:
container_name: oodikone-nginx
image: nginx:latest
depends_on:
- frontend
- backend
frontend:
condition: service_started
backend:
condition: service_healthy
ports:
- 1337:80
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
profiles:
- oodikone
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
# Following services are used to run sis-updater-worker when anon sis-db needs to be updated. Could probably also use production version, but this certainly works.
sis-importer-db:
<<: *defaults
container_name: oodikone-sis-importer-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
profiles:
- updater
sis-updater-worker:
<<: *defaults
container_name: oodikone-updater-worker
build:
context: updater/sis-updater-worker
depends_on:
- redis
- sis-db
- sis-importer-db
environment:
CI: 'true'
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_PASSWORD: postgres
profiles:
- updater
# Following services + sis-importer-db are used to run sis-updater-worker when anon sis-db needs to be updated. Could probably also use production version, but this certainly works.
sis-updater-scheduler:
<<: *defaults
restart: no
container_name: oodikone-updater-scheduler
build:
context: updater/sis-updater-scheduler
dockerfile: ./Dockerfile
depends_on:
- redis
- sis-importer-db
redis:
condition: service_healthy
sis-importer-db:
condition: service_healthy
environment:
CI: 'true'
REDIS_HOST: redis
@@ -164,3 +212,32 @@ services:
- 8082:8082
profiles:
- updater
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8082/health']
<<: *healthcheck-defaults
sis-updater-worker:
<<: *defaults
# Disabled so that container can be scaled
# container_name: oodikone-updater-worker
build:
context: updater/sis-updater-worker
dockerfile: ./Dockerfile
depends_on:
redis:
condition: service_healthy
sis-db:
condition: service_healthy
sis-importer-db:
condition: service_healthy
environment:
CI: 'true'
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SIS_PASSWORD: postgres
profiles:
- updater
+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
+28 -30
View File
@@ -1,31 +1,29 @@
services:
api-tests:
container_name: oodikone-api-tests
build:
context: services
dockerfile: backend/dev.Dockerfile
volumes:
- ./services/backend/:/opt/app-root/backend
- ./services/shared/:/opt/app-root/shared
- backend-tests-nodemod:/opt/app-root/backend/node_modules
- shared-tests-nodemod:/opt/app-root/shared/node_modules
environment:
CI: 'true'
NODE_ENV: 'test'
DB_URL_KONE: postgres://postgres@kone-db:5432/kone-db
DB_URL_USER: postgres://postgres@user-db:5432/user-db
FRONT_URL: http://localhost:3000
REDIS: redis
SECRET_TOKEN: test
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
JAMI_URL: 'http://jami:3003/'
IMPORTER_DB_API_URL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
CRYPT_KEY: 'e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
command: npm run test
name: oodikone
volumes:
backend-tests-nodemod:
name: oodikone-backend-tests-nodemod
shared-tests-nodemod:
name: oodikone-shared-tests-nodemod
x-defaults: &defaults
restart: unless-stopped
x-healthcheck-defaults: &healthcheck-defaults
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:
<<: *defaults
container_name: oodikone-sis-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
pull_policy: always
volumes: !reset [] # Remove previous volume definitions
environment:
PGDATA: /data
POSTGRES_DB: sis-db
POSTGRES_HOST_AUTH_METHOD: trust
shm_size: 2gb
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
+121 -63
View File
@@ -6,29 +6,46 @@ 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: 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: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
context: services
depends_on:
- kone-db
- redis
- sis-db
- user-db
kone-db:
condition: service_healthy
redis:
condition: service_healthy
sis-db:
condition: service_healthy
user-db:
condition: service_healthy
env_file:
- .env
tty: true
@@ -51,13 +68,19 @@ services:
- ./services/shared/:/opt/app-root/shared
- shared-nodemod:/opt/app-root/shared/node_modules
- backend-nodemod:/opt/app-root/backend/node_modules
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8080/api/health']
<<: *healthcheck-defaults
frontend:
<<: *defaults
<<: [*defaults, *user_defaults]
container_name: oodikone-frontend
build:
dockerfile: frontend/dev.Dockerfile
context: services
depends_on:
backend:
condition: service_healthy
ports:
- 3000:3000
profiles:
@@ -74,8 +97,27 @@ services:
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db
profiles:
- oodikone
ports:
- 4321:5432
volumes:
- kone-db-data:/data
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
user-db:
<<: *defaults
container_name: oodikone-user-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
profiles:
- oodikone
ports:
- 4320:5432
volumes:
- user-db-data:/data
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
redis:
<<: *defaults
@@ -83,9 +125,12 @@ services:
command: 'redis-server --appendonly yes'
image: redis:7.2.6
ports:
- '6379:6379'
- 6379:6379
volumes:
- redis-data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping', '|', 'grep', 'PONG']
<<: *healthcheck-defaults
sis-db:
<<: *defaults
@@ -96,6 +141,9 @@ services:
shm_size: 2gb
ports:
- 12345:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
sis-importer-db:
<<: *defaults
@@ -107,41 +155,55 @@ services:
- sis-importer-db-data:/data
ports:
- 12346:5432
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
<<: *healthcheck-defaults
sis-updater-scheduler:
jami:
<<: *defaults
container_name: oodikone-updater-scheduler
build:
<<: *build_defaults
context: updater/sis-updater-scheduler
container_name: oodikone-jami
image: quay.io/toska/jami:production
depends_on:
- redis
- sis-importer-db
jami-db:
condition: service_healthy
env_file:
- .env
environment:
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
ports:
- 8082:8082
profiles:
- updater
- 3003:3003
jami-db:
<<: *defaults
container_name: oodikone-jami-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/jami-db
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
ports:
- 5433:5432
volumes:
- sis-updater-scheduler-nodemod:/opt/app-root/src/node_modules
- ./updater/sis-updater-scheduler/:/opt/app-root/src
- jami-data:/data
healthcheck:
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
context: updater/sis-updater-worker
depends_on:
- redis
- sis-db
- sis-importer-db
redis:
condition: service_healthy
sis-db:
condition: service_healthy
sis-importer-db:
condition: service_healthy
environment:
DB_URL: postgres://postgres@sis-db:5432/sis-db
REDIS_HOST: redis
@@ -153,43 +215,39 @@ services:
profiles:
- updater
volumes:
- sis-updater-worker-nodemod:/opt/app-root/src/node_modules
- ./updater/sis-updater-worker/:/opt/app-root/src
- sis-updater-worker-nodemod:/opt/app-root/src/node_modules
user-db:
<<: *defaults
container_name: oodikone-user-db
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
sis-updater-scheduler:
<<: [*defaults, *user_defaults]
container_name: oodikone-updater-scheduler
build:
<<: *build_defaults
context: updater/sis-updater-scheduler
depends_on:
redis:
condition: service_healthy
sis-importer-db:
condition: service_healthy
environment:
REDIS_HOST: redis
SECRET_TOKEN: dev
SIS_IMPORTER_DATABASE: sis-importer-db
SIS_IMPORTER_HOST: sis-importer-db
SIS_IMPORTER_PASSWORD: postgres
SIS_IMPORTER_USER: postgres
SCHEDULE_IMMEDIATE: '${SCHEDULE_IMMEDIATE}'
EXIT_AFTER_IMMEDIATES: '${EXIT_AFTER_IMMEDIATES}'
ports:
- 8082:8082
profiles:
- oodikone
- updater
volumes:
- user-db-data:/data
jami:
<<: *defaults
container_name: oodikone-jami
image: quay.io/toska/jami:production
env_file:
- .env
environment:
- npm_config_cache=/tmp
- PORT=3003
- NODE_ENV=dev
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
ports:
- 3003:3003
jami-db:
<<: *defaults
container_name: oodikone-jami-db
image: postgres:15.10
environment:
- PGDATA=/data
- POSTGRES_PASSWORD=postgres
ports:
- 5433:5432
volumes:
- jami-data:/data
- ./updater/sis-updater-scheduler/:/opt/app-root/src
- sis-updater-scheduler-nodemod:/opt/app-root/src/node_modules
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8082/health']
<<: *healthcheck-defaults
volumes:
backend-nodemod:
-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())))
}
-279
View File
@@ -1,279 +0,0 @@
// @ts-check
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import globals from 'globals'
import eslintPluginCypress from 'eslint-plugin-cypress'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import eslintPluginReact from 'eslint-plugin-react'
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
import eslintPluginImportX from 'eslint-plugin-import-x'
import { createNodeResolver } from 'eslint-plugin-import-x'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
export default tseslint.config(
{
// Global ignores
ignores: [
'eslint.config.mjs',
'**/build/**',
'**/dist/**',
'**/node_modules/**',
'**/instrumented/**',
'**/coverage/**',
'nyc-config.cjs',
'.lintstagedrc.mjs',
'cypress.config.js',
],
},
// Global options (excl. e2e)
{
ignores: ['cypress/**/*.js'],
extends: [
eslint.configs.recommended,
eslintPluginImportX.flatConfigs.recommended,
eslintPluginImportX.flatConfigs.typescript,
tseslint.configs.recommendedTypeChecked,
tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
ecmaVersion: 2021,
sourceType: 'module',
parser: tseslint.parser,
parserOptions: {
projectService: {
allowDefaultProject: ['*.{js,cjs}'],
},
},
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
'import-x': eslintPluginImportX,
},
linterOptions: {
reportUnusedDisableDirectives: 'error',
},
rules: {
camelcase: 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
'class-methods-use-this': 'off', // Disabled in favor of @typescript-eslint
'consistent-return': 'off', // Disabled in favor of @typescript-eslint
'id-denylist': ['error', 'err'],
'no-alert': 'error',
'no-await-in-loop': 'error',
'no-console': 'error',
'no-implied-eval': 'off', // Disabled in favor of @typescript-eslint
'no-param-reassign': 'error',
'no-promise-executor-return': 'error',
'no-return-assign': 'error',
'no-unused-vars': 'off', // Disabled in favor of @typescript-eslint
'object-shorthand': ['error', 'always'],
'prefer-const': 'error',
'prefer-destructuring': 'off', // Disabled in favor of @typescript-eslint
'dot-notation': 'off', // Disabled in favor of @typescript-eslint
'no-restricted-imports': [
'error',
{
patterns: [
{
regex: '^@mui/[^/]+$',
message:
'Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds.',
},
{
regex: '^src/.*',
message: 'Root imports will not resolve in production build (instead silently crash).',
},
],
},
],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
'import-x/no-commonjs': 'error',
'import-x/no-default-export': 'error',
'import-x/no-extraneous-dependencies': ['error', { devDependencies: false }],
'import-x/no-unused-modules': [
'error',
{
missingExports: true,
unusedExports: true,
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}'],
},
],
'import-x/order': [
'error',
{
alphabetize: { order: 'asc', caseInsensitive: true },
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
},
],
// TypeScript
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', caughtErrors: 'none', ignoreRestSiblings: true, varsIgnorePattern: '^_' },
],
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/class-methods-use-this': 'error',
// '@typescript-eslint/consistent-indexed-object-style': 'off', // TODO: keep as error/record (default)
'@typescript-eslint/consistent-return': 'error', // NOTE: This is weaker than tsconfig.json noImplicitReturns, and should be replaced once ts migration complete
'@typescript-eslint/consistent-type-definitions': 'off',
'@typescript-eslint/dot-notation': 'error',
'@typescript-eslint/prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
'@typescript-eslint/no-explicit-any': 'off', // TODO: enable when feeling brave
'@typescript-eslint/no-implied-eval': 'error',
'@typescript-eslint/no-shadow': 'off', // TODO: enable?
'@typescript-eslint/no-unsafe-argument': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-assignment': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-call': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-member-access': 'off', // TODO: enable
'@typescript-eslint/no-unsafe-return': 'off', // TODO: enable
'@typescript-eslint/restrict-template-expressions': 'off',
},
settings: {
'import-x/internal-regex': '^@oodikone/shared/',
'import-x/resolver-next': [
createTypeScriptImportResolver({
project: [
'services/frontend/tsconfig.json',
'services/frontend/tsconfig.node.json',
'services/backend/tsconfig.json',
'services/shared/tsconfig.json',
],
}),
createNodeResolver({
extensions: ['.js', '.jsx', 'cjs', 'mjs', '.ts', '.tsx'],
}),
],
},
},
{
files: ['services/backend/src/routes/*.{ts,js}'],
rules: {
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
'@typescript-eslint/consistent-return': 'off',
},
},
// Disable type-aware linting for javascript files
{
files: ['{updater,services}/**/*.{js,cjs}'],
extends: [tseslint.configs.disableTypeChecked],
},
// Frontend camelCase and react
{
files: ['services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}'],
plugins: {
react: eslintPluginReact,
},
extends: [
eslintPluginReact.configs.flat.recommended,
eslintPluginReact.configs.flat['jsx-runtime'],
eslintPluginReactHooks.configs['recommended-latest'],
],
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
camelcase: 'warn',
'react/prop-types': 'off', // Typescript but worse
'react/no-unescaped-entities': 'off',
'react/function-component-definition': [
'error',
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
],
'react/no-array-index-key': 'error',
'react/no-unstable-nested-components': 'warn', // TODO: turn into error, reduces re-renders
'react/no-this-in-sfc': 'error',
'react/prefer-stateless-function': 'error',
'react/jsx-boolean-value': ['error', 'never'],
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
'react/jsx-no-leaked-render': 'warn', // TODO: enable, tho not that useful after full ts migration
'react/jsx-no-useless-fragment': 'error',
'react/jsx-props-no-spreading': 'warn', // TODO: enable, improves readability
'react/jsx-sort-props': ['error', { reservedFirst: false }],
'react/jsx-tag-spacing': 'warn',
},
settings: {
react: {
version: 'detect',
},
},
},
// Cypress should only use rules provided by the plugin below
{
files: ['cypress/**/*.js'],
extends: [eslintPluginCypress.configs.recommended],
},
// Override for backend api tests
{
files: ['services/backend/tests/**/*.ts'],
rules: {
'import-x/no-extraneous-dependencies': 'off',
'no-console': 'off',
},
},
// Overrides for CommonJS
{
files: [
'services/backend/**/*.js',
'updater/sis-updater-worker/src/db/migrations/*.cjs',
'updater/sis-updater-scheduler/**/*.js',
],
languageOptions: {
sourceType: 'commonjs',
},
rules: {
'import-x/no-commonjs': 'off',
'@typescript-eslint/no-require-imports': 'off',
},
},
// Migration files
{
files: ['updater/sis-updater-worker/src/db/migrations/*.cjs', 'services/backend/src/database/**/*.js'],
rules: {
'import-x/no-unused-modules': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/require-await': 'off',
},
},
// TODO: See if these can be disabled and or fix
{
files: ['services/backend/**/*.{js,ts}', 'updater/**/*.js'],
rules: {
'import-x/no-default-export': 'off',
'import-x/no-unused-modules': 'off',
'no-await-in-loop': 'off',
'no-promise-executor-return': 'off',
},
},
// Frontend tests
{
files: ['services/frontend/tests/**/*.{ts,tsx}', "services/frontend/playwright/**/*.{ts,tsx}", 'services/frontend/playwright-ct.config.ts'],
rules: {
'import-x/no-extraneous-dependencies': 'off',
'import-x/no-unused-modules': 'off',
'import-x/no-default-export': 'off',
'react/jsx-filename-extension': 'off',
},
},
eslintPluginPrettierRecommended // This must be the last plugin so it can override other configs
)
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
+1328 -2381
View File
File diff suppressed because it is too large Load Diff
+20 -22
View File
@@ -5,31 +5,34 @@
"node": ">=24.0.0"
},
"scripts": {
"cypress": "cypress",
"cypress:run": "cypress run --browser firefox",
"eslint": "eslint",
"lint:all": "eslint services/ updater/",
"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",
"oodikone:real": "./run.sh oodikone real up --detach",
"oodikone:ci:setup": "docker compose --file docker-compose.ci.yml --profile tests up --detach",
"oodikone:ci:api": "docker compose --file docker-compose.test.yml up --exit-code-from api-tests",
"oodikone:test": "npm run docker:down && docker compose --profile oodikone up --detach && docker compose --file docker-compose.test.yml up --detach",
"oodikone:ci": "npm run docker:down && docker compose --file docker-compose.ci.yml --profile oodikone up",
"updater": "./run.sh updater anon up --detach",
"updater:real": "./run.sh updater real up --detach",
"both": "./run.sh both anon up --detach",
"both:real": "./run.sh both real up --detach",
"docker:down": "./run.sh both anon down --remove-orphans; ./run.sh both real down --remove-orphans",
"testupdater": "docker compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker compose --file docker-compose.ci.yml run sis-updater-scheduler npm start",
"testupdater": "docker compose --file docker-compose.ci.yml up --detach --no-recreate --wait sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker compose --file docker-compose.ci.yml up sis-updater-scheduler --exit-code-from sis-updater-scheduler",
"flushredis": "docker exec -it oodikone-redis redis-cli flushall",
"prepare": "husky",
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
"install:docker": "docker exec oodikone-frontend npm i && docker exec oodikone-backend npm i && docker exec oodikone-updater-scheduler npm i && docker exec oodikone-updater-worker npm i",
"install:both": "npm run install:local && npm run install:docker",
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
"ci:docker": "docker exec oodikone-frontend npm ci && docker exec oodikone-backend npm ci && docker exec oodikone-updater-scheduler npm ci && docker exec oodikone-updater-worker npm ci",
"ci:docker": "docker exec oodikone-frontend sh -lc \"npm ci --prefix /opt/app-root/shared && npm ci\" && docker exec oodikone-backend npm ci && docker exec oodikone-updater-scheduler npm ci && docker exec oodikone-updater-worker npm ci",
"ci:both": "npm run ci:local && npm run ci:docker"
},
"repository": {
@@ -42,22 +45,17 @@
},
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
"devDependencies": {
"@eslint/js": "^9.28.0",
"cypress": "^15.13.0",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-cypress": "^6.2.3",
"@playwright/test": "1.61.0",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-prettier": "^5.4.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^17.4.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"prettier": "3.5.3",
"oxfmt": "0.58.0",
"oxlint": "1.73.0",
"oxlint-tsgolint": "0.25.0",
"stylelint": "^17.6.0",
"stylelint-config-standard": "^40.0.0",
"typescript-eslint": "^8.34.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'] },
},
],
})
+42 -1
View File
@@ -138,7 +138,7 @@ reset_databases() {
successmsg "Database setup finished"
}
reset_all_real_data() {
reset_all_data() {
infomsg "Downloading real data dumps"
for i in ${!DATABASES[*]}; do
local database="${DATABASES[$i]}"
@@ -209,6 +209,47 @@ reset_single_database() {
fi
}
nuke_node_modules() {
folders_to_set_up=(
"$PROJECT_ROOT"
"$PROJECT_ROOT/services/frontend"
"$PROJECT_ROOT/services/backend"
"$PROJECT_ROOT/services/shared"
"$PROJECT_ROOT/updater/sis-updater-scheduler"
"$PROJECT_ROOT/updater/sis-updater-worker"
)
named_volumes=(
"oodikone-shared-nodemod"
"oodikone-frontend-nodemod"
"oodikone-backend-nodemod"
"oodikone-updater-worker-nodemod"
"oodikone-updater-scheduler-nodemod"
)
infomsg "Running docker compose down"
"$PROJECT_ROOT"/run.sh both down --remove-orphans || infomsg "brr"
for folder in "${folders_to_set_up[@]}"; do
cd "$folder" || die "Couldn't change directory to folder $folder"
rm -rf node_modules || die "Couldn't remove node_modules in folder $folder"
done
for volume in "${named_volumes[@]}"; do
docker volume rm "$volume" || infomsg "Couldn't delete volume $volume (already gone?)"
done
infomsg "Installing npm packages locally to root and each subfolder to enable linting and formatting (and LSP)"
for folder in "${folders_to_set_up[@]}"; do
cd "$folder" || die "Couldn't change directory to folder $folder"
([[ -d node_modules ]] && die "$folder already has node_modules, exiting!") || npm ci
done
cd "$PROJECT_ROOT" || die "Couldn't change directory to project root"
successmsg "Node_module nuke performed"
}
set_up_oodikone_from_scratch() {
draw_mopo
folders_to_set_up=(
+117 -7
View File
@@ -1,7 +1,117 @@
*/.dockerignore
*/.eslintrc.json
*/.gitignore
*/*Dockerfile
*/build
*/dist
*/node_modules
# disallow directory named "private", it can be used to store things that should not end up in version control or images
# NOTE: needs to be at the root of the project
private/
gitignored/
# disallow version control, package managment, IDEs, tools etc
**/node_modules
**/.venv
**/.git
**/.github
**/.vscode
**/.idea
**/.zed
**/coverage/
**/.eslintcache
**/.DS_Store
# do not include any data by default (except json)
**/*.csv
**/*.tsv
**/*.db
**/*.sqlite
**/*.sql
**/*.psql
**/*.sqlz
**/*.sqz
**/*.sql.gz
**/*.bson
**/*.jsonl
**/*.bin
**/*.dat
**/*.data
**/*.swp
**/*.tmp
**/.~lock*
**/*.pid
**/*.seed
**/*.lock
**/*.gz
**/*.tgz
**/*.xz
**/*.zip
**/*.gzip
**/*.tar
**/*.xz
**/*.7z
**/*.rar
**/*.docx
**/*.doc
**/*.xlsx
**/*.xls
**/*.pptx
**/*.ppt
**/*.odt
**/*.odp
**/*.ods
**/*.pdf
# disallow logs
**/*.log
**/*.log*
**/logs/
**/log/
**/*.cpuprofile
**/*.heapprofile
**/*.heapsnapshot
# disallow configuration
**/*.yml
**/*.yaml
**/*.toml
**/*.conf
**/*.ini
**/toska_login/
**/toska_login/*
**/cypress.env.json
cypress/
# disallow secrects
**/.env
**/.env*
**/*.env.*
**/.secrects
**/*.s3cfg
# disallow backups
**/backups/*
**/backups
**/backup/*
**/backup
**/dump/
**/dump
**/dumps/
**/dumps
**/databasedumps/
**/privateData
**/temp
**/minio-data/*
# additional inclusions here
# exclusions here
**/.dockerignore
**/.gitignore
**/.eslintrc.json
**/*Dockerfile
**/build
**/dist
**/.npm
+12
View File
@@ -0,0 +1,12 @@
CI='true'
NODE_ENV='test'
DB_URL_KONE=postgres://postgres@localhost:4321/kone-db
DB_URL_USER=postgres://postgres@localhost:4320/user-db
FRONT_URL=http://localhost:3000
REDIS=localhost
SECRET_TOKEN=test
SIS_DB_URL=postgres://postgres@localhost:12345/sis-db
SIS_UPDATER_URL=http://localhost:8082
JAMI_URL='http://localhost:3003/'
IMPORTER_DB_API_URL='https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
CRYPT_KEY='e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
+2 -2
View File
@@ -1,4 +1,4 @@
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal AS build
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal AS build
ENV TZ="Europe/Helsinki"
@@ -29,7 +29,7 @@ RUN npm ci --include=dev --prefix shared && npm ci --include=dev --prefix backen
RUN npm run build --prefix shared && npm run build --prefix backend
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal
ENV TZ="Europe/Helsinki"
+4 -5
View File
@@ -1,21 +1,20 @@
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal
ENV TZ="Europe/Helsinki"
ENV NODE_ENV=development
ENV NODE_OPTIONS=--max-old-space-size=4096
USER 1001
WORKDIR /opt/app-root/shared
COPY --chown=1001:1001 ./shared .
COPY ./shared .
RUN npm ci
WORKDIR /opt/app-root/backend
COPY --chown=1001:1001 ./backend .
COPY ./backend .
RUN npm ci
EXPOSE 8080
CMD ["node_modules/.bin/nodemon", "--exec", "node --import tsx index.ts", "--ext", "ts,js,json", "--watch", "src", "--watch", "index.ts", "--legacy-watch"]
+1818 -365
View File
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -3,13 +3,17 @@
"private": true,
"scripts": {
"build": "tsc",
"test": "node --import=tsx --test --test-force-exit --test-reporter spec tests/**/*.ts"
"test:all": "vitest",
"test:api": "vitest ./tests/api",
"test:services": "vitest ./tests/services",
"test:unit": "vitest ./tests/unit",
"test:silent": "SILENT_TESTING=true vitest"
},
"dependencies": {
"@oodikone/shared": "file:../shared",
"@sentry/node": "^10.43.0",
"@sentry/profiling-node": "^10.43.0",
"axios": "^1.9.0",
"axios": "^1.18.1",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
@@ -18,16 +22,16 @@
"express": "^5.2.1",
"lodash-es": "^4.18.1",
"lru-cache": "^11.1.0",
"morgan": "^1.10.0",
"morgan": "^1.11.0",
"papaparse": "^5.5.3",
"pg": "^8.20.0",
"redis": "^4.7.1",
"sequelize": "^6.37.8",
"sequelize-typescript": "^2.1.6",
"umzug": "^3.8.2",
"umzug": "^3.8.3",
"unfuck-utf8-headers-middleware": "^1.0.1",
"winston": "^3.17.0",
"winston-loki": "^6.1.3"
"winston": "^3.19.0",
"winston-loki": "^6.1.5"
},
"devDependencies": {
"@types/compression": "^1.8.1",
@@ -41,6 +45,7 @@
"nodemon": "^3.1.11",
"supertest": "^7.2.2",
"tsx": "^4.19.4",
"typescript": "^5.9.2"
"typescript": "^7.0.2",
"vitest": "4.1.9"
}
}
+3 -4
View File
@@ -6,6 +6,8 @@ export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
export const runningInCI = process.env.CI === 'true'
export const silentTesting = process.env.SILENT_TESTING === 'true'
// IAM group
export const requiredGroup = ['grp-oodikone-users', 'grp-oodikone-basic-users']
@@ -36,12 +38,9 @@ export const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_U
export const rootOrgId = process.env.ROOT_ORG_ID ?? 'hy-university-root-id'
export const languageCenterViewEnabled =
process.env.LANGUAGE_CENTER_VIEW_ENABLED == null ? true : process.env.LANGUAGE_CENTER_VIEW_ENABLED === 'true'
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
+16 -2
View File
@@ -24,6 +24,7 @@ import {
TeacherModel,
} from '../models'
import {
BannerModel,
CustomPopulationSearchModel,
ExcludedCourseModel,
OpenUniPopulationSearchModel,
@@ -52,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,
@@ -106,7 +107,14 @@ 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,
ExcludedCourseModel,
OpenUniPopulationSearchModel,
@@ -123,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,46 @@
const { INTEGER, STRING, DATE } = require('sequelize')
module.exports = {
up: async ({ context: queryInterface }) => {
await queryInterface.createTable('banners', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
},
text: {
type: STRING,
allowNull: false,
},
color: {
type: STRING,
allowNull: false,
},
lightness: {
type: STRING,
allowNull: false,
},
start_date: {
type: DATE,
allowNull: false,
},
end_date: {
type: DATE,
allowNull: false,
},
last_modified_by: {
type: STRING,
allowNull: false,
},
created_at: {
type: DATE,
},
updated_at: {
type: DATE,
},
})
},
down: async ({ context: queryInterface }) => {
await queryInterface.dropTable('banners')
},
}
@@ -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')
},
}

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