diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 22968f1531b4..99cbd66265cc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -111,7 +111,7 @@ Prerequisites: - [ ] Test 2 ### Test Coverage - + diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 18aa94ff1059..f29fa7b2cfaa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -57,7 +57,7 @@ updates: # Check for version updates for Python dependencies (coverage) - package-ecosystem: "pip" - directory: "/supporting_scripts/generate_code_cov_table" + directory: "/supporting_scripts/code-coverage/generate_code_cov_table" schedule: interval: "weekly" reviewers: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9271728f3f7e..ba23a4d736bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,32 @@ jobs: ${{ env.java }} cache: 'gradle' - name: Java Tests - run: set -o pipefail && ./gradlew --console=plain test jacocoTestReport -x webapp jacocoTestCoverageVerification | tee tests.log + run: | + set -o pipefail + + DEFAULT_BRANCH="${{ github.event.repository.default_branch }}" + CURRENT_BRANCH="${{ github.ref_name }}" + + if [[ "$DEFAULT_BRANCH" != "$CURRENT_BRANCH" ]]; then + # Explicitly fetch as the clone action only clones the current branch + git fetch origin "$DEFAULT_BRANCH" + + chmod +x ./supporting_scripts/get_changed_modules.sh + CHANGED_MODULES=$(./supporting_scripts/get_changed_modules.sh "origin/$DEFAULT_BRANCH") + + # Restrict executed tests to changed modules if there is diff between this and the base branch + if [ -n "${CHANGED_MODULES}" ]; then + IFS=, + TEST_MODULE_TAGS=$(echo "-DincludeModules=${CHANGED_MODULES[*]}") + + echo "Executing tests for modules: $CHANGED_MODULES" + ./gradlew --console=plain test jacocoTestReport -x webapp jacocoTestCoverageVerification "$TEST_MODULE_TAGS" | tee tests.log + exit 0 + fi + fi + + echo "Executing all tests" + ./gradlew --console=plain test jacocoTestReport -x webapp jacocoTestCoverageVerification | tee tests.log - name: Print failed tests if: failure() run: grep "Test >.* FAILED\$" tests.log || echo "No failed tests." @@ -92,8 +117,13 @@ jobs: uses: actions/upload-artifact@v4 with: name: Coverage Report Server Tests - path: build/reports/jacoco/test/html/ - + path: build/reports/jacoco/test/html + - name: Append Per-Module Coverage to Job Summary + if: success() || failure() + run: | + AGGREGATED_REPORT_FILE=./module_coverage_report.md + python3 ./supporting_scripts/code-coverage/per_module_cov_report/parse_module_coverage.py build/reports/jacoco $AGGREGATED_REPORT_FILE + cat $AGGREGATED_REPORT_FILE > $GITHUB_STEP_SUMMARY server-tests-mysql: needs: [ server-tests ] diff --git a/build.gradle b/build.gradle index a47bc4228a47..ca407a7fb966 100644 --- a/build.gradle +++ b/build.gradle @@ -23,7 +23,7 @@ plugins { id "io.spring.dependency-management" version "1.1.7" id "nebula.lint" version "20.5.5" id "org.liquibase.gradle" version "${liquibase_plugin_version}" - id "org.owasp.dependencycheck" version "12.0.0" + id "org.owasp.dependencycheck" version "12.0.1" id "org.springframework.boot" version "${spring_boot_version}" } @@ -205,7 +205,7 @@ dependencies { implementation "org.apache.sshd:sshd-sftp:${sshd_version}" // https://mvnrepository.com/artifact/net.sourceforge.plantuml/plantuml - implementation "net.sourceforge.plantuml:plantuml:1.2024.8" + implementation "net.sourceforge.plantuml:plantuml:1.2025.0" implementation "me.xdrop:fuzzywuzzy:1.4.0" implementation("org.yaml:snakeyaml") { version { @@ -410,7 +410,7 @@ dependencies { testImplementation "org.springframework.boot:spring-boot-starter-test:${spring_boot_version}" testImplementation "org.springframework.security:spring-security-test:${spring_security_version}" testImplementation "org.springframework.boot:spring-boot-test:${spring_boot_version}" - testImplementation "org.assertj:assertj-core:3.27.2" + testImplementation "org.assertj:assertj-core:3.27.3" testImplementation "org.mockito:mockito-core:${mockito_version}" testImplementation "org.mockito:mockito-junit-jupiter:${mockito_version}" @@ -494,9 +494,10 @@ tasks.named("dependencyUpdates").configure { // 2) Execute tests with coverage report: ./gradlew test jacocoTestReport -x webapp // 2a) Execute tests without coverage report: ./gradlew test -x webapp // 2b) Run a single test: ./gradlew test --tests ExamIntegrationTest -x webapp or ./gradlew test --tests ExamIntegrationTest.testGetExamScore -x webapp -// 2c) Execute tests with Postgres container: SPRING_PROFILES_INCLUDE=postgres ./gradlew test -x webapp -// 2d) Execute tests with MySQL container: SPRING_PROFILES_INCLUDE=mysql ./gradlew test -x webapp -// 3) Verify code coverage (after tests): ./gradlew jacocoTestCoverageVerification +// 2c) Run tests for modules: ./gradlew test -DincludeModules=athena,atlas -x webapp (executes all tests in directories ./src/main/test/.../athena and ./src/main/test/.../atlas) + ArchitectureTests +// 2d) Execute tests with Postgres container: SPRING_PROFILES_INCLUDE=postgres ./gradlew test -x webapp +// 2e) Execute tests with MySQL container: SPRING_PROFILES_INCLUDE=mysql ./gradlew test -x webapp +// 3) Verify code coverage (after tests): ./gradlew jacocoTestCoverageVerification -x webapp // 4) Check Java code format: ./gradlew spotlessCheck -x webapp // 5) Apply Java code formatter: ./gradlew spotlessApply -x webapp // 6) Find dependency updates: ./gradlew dependencyUpdates -Drevision=release diff --git a/check.sh b/check.sh new file mode 100644 index 000000000000..6ba7ec7c32c8 --- /dev/null +++ b/check.sh @@ -0,0 +1,14 @@ +set -o pipefail + +chmod +x ./changed-modules.sh +CHANGED_MODULES=$(./changed-modules.sh) + +if [ -n "${CHANGED_MODULES}" ]; then + # Always execute ArchitectureTests + CHANGED_MODULES+=("ArchitectureTest") + + IFS=, + TEST_MODULE_TAGS=$(echo "-DtestTags=${CHANGED_MODULES[*]}") +fi + +echo "./gradlew --console=plain test jacocoTestReport -x webapp jacocoTestCoverageVerification $TEST_MODULE_TAGS | tee tests.log" diff --git a/docker/test-server-multi-node-postgresql-localci.yml b/docker/test-server-multi-node-postgresql-localci.yml index 124a1936aab2..366bed944595 100644 --- a/docker/test-server-multi-node-postgresql-localci.yml +++ b/docker/test-server-multi-node-postgresql-localci.yml @@ -96,6 +96,8 @@ services: artemis-app-node-3: condition: service_started restart: always + command: + - rm -rf /var/log/nginx volumes: - ./nginx/artemis-upstream-multi-node.conf:/etc/nginx/includes/artemis-upstream.conf:ro - ./nginx/artemis-ssh-upstream-multi-node.conf:/etc/nginx/includes/artemis-ssh-upstream.conf:ro diff --git a/gradle.properties b/gradle.properties index 25bb5481099d..252dc4d76d25 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,12 +28,12 @@ jplag_version=5.1.0 # NOTE: we cannot need to use the latest version 9.x or 10.x here as long as Stanford CoreNLP does not reference it lucene_version=8.11.4 slf4j_version=2.0.16 -sentry_version=7.20.0 +sentry_version=7.20.1 liquibase_version=4.31.0 docker_java_version=3.4.1 logback_version=1.5.16 java_parser_version=3.26.2 -byte_buddy_version=1.15.11 +byte_buddy_version=1.16.1 netty_version=4.1.115.Final mysql_version=9.1.0 @@ -48,7 +48,7 @@ testcontainer_version=1.20.4 gradle_node_plugin_version=7.1.0 apt_plugin_version=0.21 liquibase_plugin_version=3.0.1 -modernizer_plugin_version=1.10.0 +modernizer_plugin_version=1.11.0 spotless_plugin_version=7.0.2 org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -Duser.country=US -Duser.language=en \ diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle new file mode 100644 index 000000000000..e9dc08613dea --- /dev/null +++ b/gradle/jacoco.gradle @@ -0,0 +1,217 @@ +ext { + AggregatedCoverageThresholds = [ + "INSTRUCTION": 0.895, + "CLASS": 56 + ]; + // (Isolated) thresholds when executing each module on its own + ModuleCoverageThresholds = [ + "assessment" : [ + "INSTRUCTION": 0.779, + "CLASS": 8 + ], + "athena" : [ + "INSTRUCTION": 0.856, + "CLASS": 2 + ], + "atlas" : [ + "INSTRUCTION": 0.860, + "CLASS": 12 + ], + "buildagent" : [ + "INSTRUCTION": 0.313, + "CLASS": 13 + ], + "communication": [ + "INSTRUCTION": 0.890, + "CLASS": 7 + ], + "core" : [ + "INSTRUCTION": 0.657, + "CLASS": 69 + ], + "exam" : [ + "INSTRUCTION": 0.914, + "CLASS": 1 + ], + "exercise" : [ + "INSTRUCTION": 0.649, + "CLASS": 9 + ], + "fileupload" : [ + "INSTRUCTION": 0.906, + "CLASS": 0 + ], + "iris" : [ + "INSTRUCTION": 0.795, + "CLASS": 17 + ], + "lecture" : [ + "INSTRUCTION": 0.867, + "CLASS": 0 + ], + "lti" : [ + "INSTRUCTION": 0.770, + "CLASS": 3 + ], + "modeling" : [ + "INSTRUCTION": 0.891, + "CLASS": 2 + ], + "plagiarism" : [ + "INSTRUCTION": 0.760, + "CLASS": 1 + ], + "programming" : [ + "INSTRUCTION": 0.863, + "CLASS": 12 + ], + "quiz" : [ + "INSTRUCTION": 0.784, + "CLASS": 6 + ], + "text" : [ + "INSTRUCTION": 0.847, + "CLASS": 0 + ], + "tutorialgroup": [ + "INSTRUCTION": 0.915, + "CLASS": 0 + ], + ] + // If no explicit modules defined -> generate reports and validate for each module + reportedModules = includedModules.size() == 0 + ? ModuleCoverageThresholds.collect {element -> element.key} + : includedModules as ArrayList + + // we want to ignore some generated files in the domain folders + ignoredDirectories = [ + "**/$BasePath/**/domain/**/*_*", + "**/$BasePath/core/config/migration/entries/**", + "**/gradle-wrapper.jar/**" + ] +} + +jacoco { + toolVersion = "0.8.12" +} + +jacocoTestReport { + // For the aggregated report + reports { + xml.required = true + xml.outputLocation = file("build/reports/jacoco/test/jacocoTestReport.xml") + html.required = true + html.outputLocation = file("build/reports/jacoco/test/html") + } + + finalizedBy reportedModules + .collect { module -> registerJacocoReportTask(module as String, jacocoTestReport) } + .findAll { task -> task != null} +} + +jacocoTestCoverageVerification { + // Only run full coverage when no specific modules set + enabled = reportedModules.size() == 0 + + def minInstructionCoveredRatio = AggregatedCoverageThresholds["INSTRUCTION"] as double + def maxNumberUncoveredClasses = AggregatedCoverageThresholds["CLASS"] as int + applyVerificationRule(jacocoTestCoverageVerification, minInstructionCoveredRatio, maxNumberUncoveredClasses) + + finalizedBy reportedModules + .collect { module -> registerJacocoTestCoverageVerification(module as String, jacocoTestCoverageVerification) } + .findAll { task -> task != null} +} +check.dependsOn jacocoTestCoverageVerification + +/** + * Registers a JacocoReport task based on the provided parameters. + * + * @param moduleName The module name to include in the report. + * @param rootTask The root JacocoReport root task. + * @return The configured JacocoReport task. + */ +private JacocoReport registerJacocoReportTask(String moduleName, JacocoReport rootTask) { + def taskName = "jacocoCoverageReport-$moduleName" + + JacocoReport task = project.tasks.register(taskName, JacocoReport).get() + task.description = "Generates JaCoCo coverage report for $moduleName" + + prepareJacocoReportTask(task, moduleName, rootTask) + + task.reports { + xml.required = true + xml.outputLocation = file("build/reports/jacoco/$moduleName/jacocoTestReport.xml") + html.required = true + html.outputLocation = file("build/reports/jacoco/$moduleName/html") + } + + return task +} + +/** + * Registers a JacocoCoverageVerification task based on the provided parameters. + * + * @param moduleName The module name to validate rules for. + * @param rootTask The root JacocoCoverageVerification task. + * @return The configured JacocoCoverageVerification task. + */ +private JacocoCoverageVerification registerJacocoTestCoverageVerification(String moduleName, JacocoCoverageVerification rootTask) { + def taskName = "jacocoTestCoverageVerification-$moduleName" + + def thresholds = ModuleCoverageThresholds[moduleName] + if (thresholds == null) { + println "No coverage thresholds defined for module '$moduleName'. Skipping verification for this module..." + return null + } + def minInstructionCoveredRatio = thresholds["INSTRUCTION"] as double + def maxNumberUncoveredClasses = thresholds["CLASS"] as int + + JacocoCoverageVerification task = project.tasks.register(taskName, JacocoCoverageVerification).get() + task.description = "Validates JaCoCo coverage for vioalations for $moduleName" + + prepareJacocoReportTask(task, moduleName, rootTask) + applyVerificationRule(task, minInstructionCoveredRatio, maxNumberUncoveredClasses) + + return task +} + +/** + * Prepares a Jacoco report task (report & verification) to match a specific module. + * @param task that is modified + * @param moduleName of the module. + * @param rootTask the JacocoReportBase root task + */ +private void prepareJacocoReportTask(JacocoReportBase task, String moduleName, JacocoReportBase rootTask) { + task.group = "Reporting" + task.executionData = project.fileTree("${project.layout.buildDirectory.get()}/jacoco") { + include "test.exec" + } + + def modulePath = "$BasePath/$moduleName/**/*.class" + task.sourceDirectories.setFrom(project.files("src/main/java/$modulePath")) + task.classDirectories.setFrom( + files(rootTask.classDirectories.files.collect { classDir -> + project.fileTree(classDir) { + includes=[modulePath] + excludes=ignoredDirectories + } + }) + ) +} + +private static void applyVerificationRule(JacocoCoverageVerification task, double minInstructionCoveredRatio, int maxNumberUncoveredClasses) { + task.violationRules { + rule { + limit { + counter = "INSTRUCTION" + value = "COVEREDRATIO" + minimum = minInstructionCoveredRatio + } + limit { + counter = "CLASS" + value = "MISSEDCOUNT" + maximum = maxNumberUncoveredClasses + } + } + } +} diff --git a/gradle/test.gradle b/gradle/test.gradle index 5005a226659b..32ae20c66731 100644 --- a/gradle/test.gradle +++ b/gradle/test.gradle @@ -45,17 +45,39 @@ tasks.withType(Test).configureEach { } } +// Allow using in jacoco.gradle +ext { + includedTestTags = System.getProperty("includeTags") + includedTags = !includedTestTags ? new String[]{} : includedTestTags.split(",") as String[] + includedModulesTag = System.getProperty("includeModules") + includedModules = !includedModulesTag ? new String[]{} : includedModulesTag.split(",") as String[] + + runAllTests = includedTags.size() == 0 && includedModules.size() == 0 + BasePath = "de/tum/cit/aet/artemis" +} + // Execute the test cases: ./gradlew test // Execute only architecture tests: ./gradlew test -DincludeTags="ArchitectureTest" +// Execute tests only for specific modules: ./gradlew test -DincludeModules="atlas". Using this flag, "includeTags" will be ignored. test { - if (System.getProperty("includeTags")) { - useJUnitPlatform { - includeTags System.getProperty("includeTags") + if (runAllTests) { + useJUnitPlatform() + exclude "**/*IT*", "**/*IntTest*" + } else if (includedModules.size() == 0) { + // not running all tests, but not module-specific ones -> use tags + useJUnitPlatform() { + includeTags includedTags } } else { useJUnitPlatform() - exclude "**/*IT*", "**/*IntTest*" + // Always execute "shared"-folder when executing module-specifc tests + includedModules += "shared" + filter { testFilter -> + includedModules.each { val -> + testFilter.includeTestsMatching("de.tum.cit.aet.artemis.$val.*") + } + } } testClassesDirs = testing.suites.test.sources.output.classesDirs classpath = testing.suites.test.sources.runtimeClasspath @@ -68,59 +90,5 @@ test { maxHeapSize = "6g" // maximum heap size } -tasks.register("testReport", TestReport) { - destinationDirectory = layout.buildDirectory.file("reports/tests").get().asFile - testResults.from(test) -} - -apply plugin: "jacoco" - -jacoco { - toolVersion = "0.8.12" -} - -private excludedClassFilesForReport(classDirectories) { - classDirectories.setFrom(files(classDirectories.files.collect { - fileTree(dir: it, - exclude: [ - "**/de/tum/cit/aet/artemis/**/domain/**/*_*", - "**/de/tum/cit/aet/artemis/core/config/migration/entries/**", - "**/gradle-wrapper.jar/**" - ] - ) - })) -} - -jacocoTestReport { - reports { - xml.required = true - } - // we want to ignore some generated files in the domain folders - afterEvaluate { - excludedClassFilesForReport(classDirectories) - } -} - -jacocoTestCoverageVerification { - violationRules { - rule { - limit { - counter = "INSTRUCTION" - value = "COVEREDRATIO" - // TODO: in the future the following value should become higher than 0.92 - minimum = 0.891 - } - limit { - counter = "CLASS" - value = "MISSEDCOUNT" - // TODO: in the future the following value should become less than 10 - maximum = 57 - } - } - } - // we want to ignore some generated files in the domain folders - afterEvaluate { - excludedClassFilesForReport(classDirectories) - } -} -check.dependsOn jacocoTestCoverageVerification +// Dynamic generation of jacoco test report generation-/coverage verification-tasks (per-module) +apply from: "gradle/jacoco.gradle" diff --git a/package-lock.json b/package-lock.json index 540d2b4903df..5cb056c3dfaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "monaco-editor": "0.52.2", "ngx-infinite-scroll": "19.0.0", "ngx-webstorage": "19.0.1", + "pako": "2.1.0", "papaparse": "5.5.1", "pdf-lib": "1.17.1", "pdfjs-dist": "4.10.38", @@ -102,6 +103,7 @@ "@types/lodash-es": "4.17.12", "@types/markdown-it": "14.1.2", "@types/node": "22.10.7", + "@types/pako": "2.0.3", "@types/papaparse": "5.3.15", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", @@ -6490,6 +6492,12 @@ "pako": "^1.0.6" } }, + "node_modules/@pdf-lib/standard-fonts/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/@pdf-lib/upng": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", @@ -6499,6 +6507,12 @@ "pako": "^1.0.10" } }, + "node_modules/@pdf-lib/upng/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -7746,6 +7760,13 @@ "@types/node": "*" } }, + "node_modules/@types/pako": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.3.tgz", + "integrity": "sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/papaparse": { "version": "5.3.15", "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.15.tgz", @@ -15321,6 +15342,12 @@ "setimmediate": "^1.0.5" } }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/karma-source-map-support": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", @@ -17561,9 +17588,9 @@ } }, "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, "node_modules/papaparse": { @@ -17883,6 +17910,12 @@ "tslib": "^1.11.1" } }, + "node_modules/pdf-lib/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/pdf-lib/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", diff --git a/package.json b/package.json index aee17ca68d2f..0449da8c7368 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "monaco-editor": "0.52.2", "ngx-infinite-scroll": "19.0.0", "ngx-webstorage": "19.0.1", + "pako": "2.1.0", "papaparse": "5.5.1", "pdf-lib": "1.17.1", "pdfjs-dist": "4.10.38", @@ -151,6 +152,7 @@ "@types/lodash-es": "4.17.12", "@types/markdown-it": "14.1.2", "@types/node": "22.10.7", + "@types/pako": "2.0.3", "@types/papaparse": "5.3.15", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", diff --git a/per-module-coverage-recorder.sh b/per-module-coverage-recorder.sh new file mode 100755 index 000000000000..315a7acfe941 --- /dev/null +++ b/per-module-coverage-recorder.sh @@ -0,0 +1,6 @@ +MODULES=("assessment" "athena" "atlas" "buildagent" "communication" "core" "exam" "exercise" "fileupload" "iris" + "lecture" "lti" "modeling" "plagiarism" "programming" "quiz" "text" "tutorialgroup") + +for module in "${MODULES[@]}"; do + ./gradlew test jacocoTestReport -x webapp jacocoTestCoverageVerification -DincludeModules="$module" || true +done diff --git a/read.ms b/read.ms new file mode 100644 index 000000000000..68e8407fbb7e --- /dev/null +++ b/read.ms @@ -0,0 +1,5 @@ +## Coverage Results + +| Module Name | Instruction Coverage (%) | Missed Classes | +|-------------|---------------------------|----------------| +| athena | 85.63 | 2 | diff --git a/result.md b/result.md new file mode 100644 index 000000000000..0bb4b11cdf12 --- /dev/null +++ b/result.md @@ -0,0 +1,5 @@ +## Coverage Results + +| Module Name | Instruction Coverage (%) | Missed Classes | +|-------------|---------------------------|----------------| +| atlas | 84.64 | 13 | diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java index a78718f35a39..13125a832c5e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java @@ -42,6 +42,8 @@ import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; +import de.tum.cit.aet.artemis.core.security.allowedTools.AllowedTools; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastTutor; @@ -165,6 +167,7 @@ public ResponseEntity getResult(@PathVariable Long participationId, @Pat */ @GetMapping("participations/{participationId}/results/{resultId}/details") @EnforceAtLeastStudent + @AllowedTools(ToolTokenType.SCORPIO) public ResponseEntity> getResultDetails(@PathVariable Long participationId, @PathVariable Long resultId) { log.debug("REST request to get details of Result : {}", resultId); Result result = resultRepository.findByIdWithEagerFeedbacksElseThrow(resultId); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/GroupChatRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/GroupChatRepository.java index 5cf28c6a4d13..ea0dafcb30eb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/GroupChatRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/GroupChatRepository.java @@ -3,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.util.List; +import java.util.Optional; import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.Query; @@ -38,5 +39,39 @@ public interface GroupChatRepository extends ArtemisJpaRepository findGroupChatsOfUserWithParticipantsAndUserGroups(@Param("courseId") Long courseId, @Param("userId") Long userId); + /** + * Find an existing group chat in a given course with the exact set of participants. + *

+ * This query checks if a group chat exists with the specified participants in a course. + * It ensures that: + * - The group chat contains all the provided participants. + * - The group chat does not include any additional participants. + *

+ * The query uses two subqueries: + * 1. The first subquery counts how many of the given participants are in the group chat. + * It ensures that all provided participants are part of the group. + * 2. The second subquery counts all participants in the group chat. + * It ensures that there are no extra participants outside the provided set. + * + * @param courseId the ID of the course in which to search for the group chat + * @param participantIds the IDs of the users to search for in the group chat + * @param participantCount the total number of participants expected in the group chat + * @return an optional group chat with the exact set of participants, if one exists + */ + @Query(""" + SELECT gc + FROM GroupChat gc + WHERE gc.course.id = :courseId + AND (SELECT COUNT(cp) + FROM gc.conversationParticipants cp + WHERE cp.user.id IN :participantIds + ) = :participantCount + AND (SELECT COUNT(cp2) + FROM gc.conversationParticipants cp2 + ) = :participantCount + """) + Optional findGroupChatWithExactParticipants(@Param("courseId") Long courseId, @Param("participantIds") List participantIds, + @Param("participantCount") long participantCount); + Integer countByCreatorIdAndCourseId(Long creatorId, Long courseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/WebsocketMessagingService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/WebsocketMessagingService.java index b28b70cfe6e8..50eb995f1d3b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/WebsocketMessagingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/WebsocketMessagingService.java @@ -1,9 +1,13 @@ package de.tum.cit.aet.artemis.communication.service; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; +import static de.tum.cit.aet.artemis.core.config.websocket.GzipMessageConverter.COMPRESSION_HEADER; +import java.util.Collection; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; +import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,16 +59,17 @@ public CompletableFuture sendMessage(String topic, Message message) { * The message will be sent asynchronously. * * @param topic the destination to which subscription the message should be sent - * @param message any object that should be sent to the destination (topic), this will typically get transformed into json + * @param payload the payload to send in the message (e.g. a record DTO), which will be transformed into json and potentially compressed * @return a future that can be used to check if the message was sent successfully or resulted in an exception */ - public CompletableFuture sendMessage(String topic, Object message) { + public CompletableFuture sendMessage(String topic, Object payload) { try { - return CompletableFuture.runAsync(() -> messagingTemplate.convertAndSend(topic, message), asyncExecutor); + Map headers = shouldCompress(topic, payload) ? COMPRESSION_HEADER : Map.of(); + return CompletableFuture.runAsync(() -> messagingTemplate.convertAndSend(topic, payload, headers), asyncExecutor); } // Note: explicitly catch ALL kinds of exceptions here and do NOT rethrow, because the actual task should NEVER be interrupted when the server cannot send WS messages catch (Exception ex) { - log.error("Error when sending message {} to topic {}", message, topic, ex); + log.error("Error when sending payload {} to topic {}", payload, topic, ex); return CompletableFuture.failedFuture(ex); } } @@ -75,17 +80,67 @@ public CompletableFuture sendMessage(String topic, Object message) { * * @param user the user that should receive the message. * @param topic the destination to send the message to - * @param payload the payload to send + * @param payload the payload to send in the message (e.g. a record DTO), which will be transformed into json and potentially compressed * @return a future that can be used to check if the message was sent successfully or resulted in an exception */ public CompletableFuture sendMessageToUser(String user, String topic, Object payload) { try { - return CompletableFuture.runAsync(() -> messagingTemplate.convertAndSendToUser(user, topic, payload), asyncExecutor); + Map headers = shouldCompress(topic, payload) ? COMPRESSION_HEADER : Map.of(); + return CompletableFuture.runAsync(() -> messagingTemplate.convertAndSendToUser(user, topic, payload, headers), asyncExecutor); } // Note: explicitly catch ALL kinds of exceptions here and do NOT rethrow, because the actual task should NEVER be interrupted when the server cannot send WS messages catch (Exception ex) { - log.error("Error when sending message {} on topic {} to user {}", payload, topic, user, ex); + log.error("Error when sending payload {} on topic {} to user {}", payload, topic, user, ex); return CompletableFuture.failedFuture(ex); } } + + /** + * A regex pattern to match compressible WebSocket topics. + *

+ * The topics covered by this pattern are: + * 1. Topics for course-specific job statuses: + * - `/topic/courses/{courseId}/queued-jobs` + * - `/topic/courses/{courseId}/running-jobs` + * - `{courseId}` is a numeric identifier (long). + *

+ * 2. Topics for admin-level job statuses and build agents: + * - `/topic/admin/queued-jobs` + * - `/topic/admin/running-jobs` + * - `/topic/admin/build-agents` + *

+ * 3. Topics for specific build agent details: + * - `/topic/admin/build-agent/{buildAgentName}` + * - `{buildAgentName}` is a string that does not contain a forward slash (`/`). + *

+ * Regex Details: + * - `^/topic/courses/\\d+/(queued-jobs|running-jobs)`: + * Matches topics for course-specific jobs with a numeric `courseId`. + * - `|^/topic/admin/(queued-jobs|running-jobs|build-agents)`: + * Matches admin-level job and build agent topics. + * - `|^/topic/admin/build-agent/[^/]+$`: + * Matches specific build agent topics, where `{buildAgentName}` is any string excluding `/`. + */ + private static final Pattern COMPRESSIBLE_TOPICS = Pattern + .compile("^/topic/courses/\\d+/(queued-jobs|running-jobs)|" + "^/topic/admin/(queued-jobs|running-jobs|build-agents)|" + "^/topic/admin/build-agent/[^/]+$"); + + /** + * Determine if a message for a specific topic should be compressed. + */ + private static boolean shouldCompress(String topic, Object payload) { + // Only compress messages for specific topics + if (topic == null) { + return false; + } + if (isEmpty(payload)) { + return false; + } + // Match the topic against the regex + return COMPRESSIBLE_TOPICS.matcher(topic).matches(); + } + + private static boolean isEmpty(Object payload) { + return payload == null || payload.toString().isEmpty() || (payload instanceof Collection collection && collection.isEmpty()) + || (payload instanceof Map map && map.isEmpty()); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/GroupChatService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/GroupChatService.java index 30af11786b0b..9611e5786f94 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/GroupChatService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/GroupChatService.java @@ -52,6 +52,16 @@ public GroupChatService(UserRepository userRepository, ConversationParticipantRe */ public GroupChat startGroupChat(Course course, Set startingMembers) { var requestingUser = userRepository.getUserWithGroupsAndAuthorities(); + var participantIds = startingMembers.stream().map(User::getId).toList(); + var existingChatBetweenUsers = groupChatRepository.findGroupChatWithExactParticipants(course.getId(), participantIds, startingMembers.size()); + if (existingChatBetweenUsers.isPresent()) { + GroupChat chat = existingChatBetweenUsers.get(); + if (chat.getLastMessageDate() == null && !requestingUser.getId().equals(chat.getCreator().getId())) { + chat.setCreator(requestingUser); + return groupChatRepository.save(existingChatBetweenUsers.get()); + } + return existingChatBetweenUsers.get(); + } var groupChat = new GroupChat(); groupChat.setCourse(course); groupChat.setCreator(requestingUser); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java index f9fcd4a58986..b6615d137a97 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java @@ -221,6 +221,14 @@ public ResponseEntity createChannel(@PathVariable Long courseId, @Re checkCommunicationEnabledElseThrow(course); channelAuthorizationService.isAllowedToCreateChannel(course, requestingUser); + var channelToCreate = new Channel(); + channelToCreate.setName(channelDTO.getName()); + channelToCreate.setIsPublic(channelDTO.getIsPublic()); + channelToCreate.setIsAnnouncementChannel(channelDTO.getIsAnnouncementChannel()); + channelToCreate.setIsArchived(false); + channelToCreate.setDescription(channelDTO.getDescription()); + channelToCreate.setIsCourseWide(channelDTO.getIsCourseWide()); + if (channelDTO.getName() != null && channelDTO.getName().trim().startsWith("$")) { throw new BadRequestAlertException("User generated channels cannot start with $", "channel", "channelNameInvalid"); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/WebConfigurer.java b/src/main/java/de/tum/cit/aet/artemis/core/config/WebConfigurer.java index e79b1de3939c..c7bf24bc12aa 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/WebConfigurer.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/WebConfigurer.java @@ -28,7 +28,10 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolsInterceptor; import de.tum.cit.aet.artemis.core.security.filter.CachingHttpHeadersFilter; import tech.jhipster.config.JHipsterProperties; @@ -37,7 +40,7 @@ */ @Profile(PROFILE_CORE) @Configuration -public class WebConfigurer implements ServletContextInitializer, WebServerFactoryCustomizer { +public class WebConfigurer implements ServletContextInitializer, WebServerFactoryCustomizer, WebMvcConfigurer { private static final Logger log = LoggerFactory.getLogger(WebConfigurer.class); @@ -45,9 +48,12 @@ public class WebConfigurer implements ServletContextInitializer, WebServerFactor private final JHipsterProperties jHipsterProperties; - public WebConfigurer(Environment env, JHipsterProperties jHipsterProperties) { + private final ToolsInterceptor toolsInterceptor; + + public WebConfigurer(Environment env, JHipsterProperties jHipsterProperties, ToolsInterceptor toolsInterceptor) { this.env = env; this.jHipsterProperties = jHipsterProperties; + this.toolsInterceptor = toolsInterceptor; } @Override @@ -126,4 +132,10 @@ public CorsFilter corsFilter() { } return new CorsFilter(source); } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(toolsInterceptor).addPathPatterns("/api/**").excludePathPatterns("/api/public/**"); + ; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/GzipMessageConverter.java b/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/GzipMessageConverter.java new file mode 100644 index 000000000000..486b512295a4 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/GzipMessageConverter.java @@ -0,0 +1,138 @@ +package de.tum.cit.aet.artemis.core.config.websocket; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.support.NativeMessageHeaderAccessor; + +import com.fasterxml.jackson.databind.ObjectMapper; + +public class GzipMessageConverter extends MappingJackson2MessageConverter { + + private static final Logger log = LoggerFactory.getLogger(GzipMessageConverter.class); + + public static final String COMPRESSION_HEADER_KEY = "X-Compressed"; + + public static final Map COMPRESSION_HEADER = Map.of(COMPRESSION_HEADER_KEY, true); + + public GzipMessageConverter(ObjectMapper objectMapper) { + super(objectMapper); + } + + @Override + protected boolean supports(Class clazz) { + return true; + } + + // Incoming message from client, potentially compressed, needs to be decompressed + @Override + protected Object convertFromInternal(Message message, Class targetClass, Object conversionHint) { + var nativeHeaders = message.getHeaders().get(NativeMessageHeaderAccessor.NATIVE_HEADERS); + if (nativeHeaders instanceof Map nativeMapHeaders) { + final var messageIsCompressed = containsCompressionHeader(nativeMapHeaders); + if (messageIsCompressed) { + log.info("Decompressing message payload for incoming message"); + Object payload = message.getPayload(); + if (payload instanceof byte[] bytePayload) { + byte[] decompressed = decodeAndDecompress(bytePayload); + return super.convertFromInternal(new Message<>() { + + @Override + public Object getPayload() { + return decompressed; + } + + @Override + public MessageHeaders getHeaders() { + return message.getHeaders(); + } + }, targetClass, conversionHint); + } + } + } + return super.convertFromInternal(message, targetClass, conversionHint); + } + + private static boolean containsCompressionHeader(Map headers) { + var value = headers.get(COMPRESSION_HEADER_KEY); + if (value instanceof List list && !list.isEmpty()) { + return checkSimpleValue(list.getFirst()); + } + return checkSimpleValue(value); + } + + private static boolean checkSimpleValue(Object value) { + if (value instanceof Boolean booleanValue) { + return Boolean.TRUE.equals(booleanValue); + } + if (value instanceof String stringValue) { + return Boolean.parseBoolean(stringValue); + } + return false; + } + + // Outgoing message to client, potentially compressible, needs to be compressed + // NOTE: headers is immutable here and cannot be modified + @Override + protected Object convertToInternal(Object payload, MessageHeaders headers, Object conversionHint) { + Object original = super.convertToInternal(payload, headers, conversionHint); + if (original instanceof byte[] originalBytes) { + // Check the native headers to see if the message should be compressed + var nativeHeaders = headers.get(NativeMessageHeaderAccessor.NATIVE_HEADERS); + if (nativeHeaders instanceof Map nativeMapHeaders) { + boolean shouldCompress = containsCompressionHeader(nativeMapHeaders); + if (shouldCompress) { + String compressedBase64String = compressAndEncode(originalBytes); + byte[] compressed = compressedBase64String.getBytes(StandardCharsets.UTF_8); + double percentageSaved = 100 * (1 - (double) compressed.length / originalBytes.length); + log.debug("Compressed message payload from {} to {} (saved {}% payload size)", originalBytes.length, compressed.length, String.format("%.1f", percentageSaved)); + return compressed; + } + } + return originalBytes; + } + return original; + } + + // NOTE: we use a hybrid approach here mixing string based and binary data when compression is active. + // As a compromise, we use Base64 encoding to ensure that the compressed data can be safely transmitted as a string (without interfering with the WebSocket protocol). + // This can still reduce the payload size by up to 95% (for large payloads) compared to the original binary data (in standard json). + private String compressAndEncode(byte[] data) { + try (ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteStream)) { + gzipOutputStream.write(data); + gzipOutputStream.finish(); + return Base64.getEncoder().encodeToString(byteStream.toByteArray()); + } + catch (Exception e) { + throw new RuntimeException("Failed to compressAndEncode message payload", e); + } + } + + private byte[] decodeAndDecompress(byte[] data) { + // Step 1: Decode Base64 to binary + byte[] binaryData = Base64.getDecoder().decode(data); + + // Step 2: Decompress the binary data + try (ByteArrayInputStream byteStream = new ByteArrayInputStream(binaryData); + GZIPInputStream gzipStream = new GZIPInputStream(byteStream); + ByteArrayOutputStream outStream = new ByteArrayOutputStream()) { + // Efficiently transfers all bytes + gzipStream.transferTo(outStream); + return outStream.toByteArray(); + } + catch (Exception e) { + throw new RuntimeException("Failed to decodeAndDecompress message payload", e); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java index 4a5dfdacfa24..9501569ac6c3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java @@ -35,6 +35,7 @@ import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.messaging.simp.stomp.StompCommand; @@ -141,6 +142,13 @@ protected void configureMessageBroker(@NotNull MessageBrokerRegistry config) { } } + @Override + protected boolean configureMessageConverters(List messageConverters) { + GzipMessageConverter gzipMessageConverter = new GzipMessageConverter(objectMapper); + messageConverters.add(gzipMessageConverter); + return false; + } + /** * Create a TCP client that will connect to the broker defined in the config. * If multiple brokers are configured, the client will connect to the first one and fail over to the next one in case a broker goes down. @@ -181,12 +189,7 @@ public void configureClientInboundChannel(ChannelRegistration registration) { @NotNull @Override protected MappingJackson2MessageConverter createJacksonConverter() { - // NOTE: We need to adapt the default messageConverter for WebSocket messages - // with a messageConverter that uses the same ObjectMapper that our REST endpoints use. - // This gives us consistency in how specific data types are serialized (e.g. timestamps) - MappingJackson2MessageConverter converter = super.createJacksonConverter(); - converter.setObjectMapper(objectMapper); - return converter; + return new GzipMessageConverter(objectMapper); } /** @@ -308,7 +311,7 @@ private boolean allowSubscription(@Nullable Principal principal, String destinat return isParticipationOwnedByUser(principal, participationId); } if (isNonPersonalExerciseResultDestination(destination)) { - final var exerciseId = getExerciseIdFromNonPersonalExerciseResultDestination(destination).orElseThrow(); + final long exerciseId = getExerciseIdFromNonPersonalExerciseResultDestination(destination).orElseThrow(); // TODO: Is it right that TAs are not allowed to subscribe to exam exercises? if (exerciseRepository.isExamExercise(exerciseId)) { diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/AllowedTools.java b/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/AllowedTools.java new file mode 100644 index 000000000000..d6c8d6929837 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/AllowedTools.java @@ -0,0 +1,22 @@ +package de.tum.cit.aet.artemis.core.security.allowedTools; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify the tools allowed for certain methods or classes. + * Used to restrict access or customize behavior based on the provided {@link ToolTokenType}. + */ +@Target({ ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface AllowedTools { + + /** + * Returns the allowed tools specified in the annotation of the method or class. + * + * @return the allowed tools specified by the annotation + */ + ToolTokenType[] value(); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolTokenType.java b/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolTokenType.java new file mode 100644 index 000000000000..4159656d94bf --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolTokenType.java @@ -0,0 +1,5 @@ +package de.tum.cit.aet.artemis.core.security.allowedTools; + +public enum ToolTokenType { + SCORPIO +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolsInterceptor.java b/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolsInterceptor.java new file mode 100644 index 000000000000..d9c3288e4955 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolsInterceptor.java @@ -0,0 +1,72 @@ +package de.tum.cit.aet.artemis.core.security.allowedTools; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import de.tum.cit.aet.artemis.core.security.jwt.JWTFilter; +import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider; + +@Profile(PROFILE_CORE) +@Component +public class ToolsInterceptor implements HandlerInterceptor { + + private final TokenProvider tokenProvider; + + public ToolsInterceptor(TokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String jwtToken; + try { + jwtToken = JWTFilter.extractValidJwt(request, tokenProvider); + } + catch (IllegalArgumentException e) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + return false; + } + + if (handler instanceof HandlerMethod && jwtToken != null) { + HandlerMethod handlerMethod = (HandlerMethod) handler; + Method method = handlerMethod.getMethod(); + + // Check if the method or its class has the @AllowedTools annotation + AllowedTools allowedToolsAnnotation = method.getAnnotation(AllowedTools.class); + if (allowedToolsAnnotation == null) { + allowedToolsAnnotation = method.getDeclaringClass().getAnnotation(AllowedTools.class); + } + + // Extract the "tools" claim from the JWT token + String toolsClaim = tokenProvider.getClaim(jwtToken, "tools", String.class); + + // If no @AllowedTools annotation is present and the token is a tool token, reject the request + if (allowedToolsAnnotation == null && toolsClaim != null) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access denied due to 'tools' claim."); + return false; + } + + // If @AllowedTools is present, check if the toolsClaim is among the allowed values + if (allowedToolsAnnotation != null && toolsClaim != null) { + ToolTokenType[] allowedTools = allowedToolsAnnotation.value(); + // no match between allowed tools and tools claim + var toolsClaimList = toolsClaim.split(","); + if (Arrays.stream(allowedTools).noneMatch(tool -> Arrays.asList(toolsClaimList).contains(tool.toString()))) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access denied due to 'tools' claim."); + return false; + } + } + } + return true; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java index 73320dee2813..88d8d6405d51 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java @@ -14,6 +14,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; + @Profile(PROFILE_CORE) @Service public class JWTCookieService { @@ -36,9 +38,30 @@ public JWTCookieService(TokenProvider tokenProvider, Environment environment) { * @return the login ResponseCookie containing the JWT */ public ResponseCookie buildLoginCookie(boolean rememberMe) { - String jwt = tokenProvider.createToken(SecurityContextHolder.getContext().getAuthentication(), rememberMe); - Duration duration = Duration.of(tokenProvider.getTokenValidity(rememberMe), ChronoUnit.MILLIS); - return buildJWTCookie(jwt, duration); + return buildLoginCookie(rememberMe, null); + } + + /** + * Builds the cookie containing the jwt for a login + * + * @param rememberMe boolean used to determine the duration of the jwt. + * @param tool the tool claim in the jwt + * @return the login ResponseCookie containing the JWT + */ + public ResponseCookie buildLoginCookie(boolean rememberMe, ToolTokenType tool) { + return buildLoginCookie(tokenProvider.getTokenValidity(rememberMe), tool); + } + + /** + * Builds a cookie with the tool claim in the jwt + * + * @param duration the duration of the cookie in milli seconds and the jwt + * @param tool the tool claim in the jwt + * @return the login ResponseCookie containing the JWT + */ + public ResponseCookie buildLoginCookie(long duration, ToolTokenType tool) { + String jwt = tokenProvider.createToken(SecurityContextHolder.getContext().getAuthentication(), duration, tool); + return buildJWTCookie(jwt, Duration.of(duration, ChronoUnit.MILLIS)); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java index 98cb4560811d..d95a3e9de431 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java @@ -25,6 +25,7 @@ import org.springframework.util.StringUtils; import de.tum.cit.aet.artemis.core.management.SecurityMetersService; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -96,11 +97,27 @@ public long getTokenValidity(boolean rememberMe) { * @return JWT Token */ public String createToken(Authentication authentication, boolean rememberMe) { + return createToken(authentication, getTokenValidity(rememberMe), null); + } + + /** + * Create JWT Token a fully populated Authentication object. + * + * @param authentication Authentication Object + * @param duration the Token lifetime in milli seconds + * @param tool tool this token is used for. If null, it's a general access token + * @return JWT Token + */ + public String createToken(Authentication authentication, long duration, @Nullable ToolTokenType tool) { String authorities = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")); - long now = (new Date()).getTime(); - Date validity = new Date(now + getTokenValidity(rememberMe)); - return Jwts.builder().subject(authentication.getName()).claim(AUTHORITIES_KEY, authorities).signWith(key, Jwts.SIG.HS512).expiration(validity).compact(); + var validity = System.currentTimeMillis() + duration; + var jwtBuilder = Jwts.builder().subject(authentication.getName()).claim(AUTHORITIES_KEY, authorities); + if (tool != null) { + jwtBuilder.claim("tools", tool); + } + + return jwtBuilder.signWith(key, Jwts.SIG.HS512).expiration(new Date(validity)).compact(); } /** @@ -172,6 +189,11 @@ private Claims parseClaims(String authToken) { return Jwts.parser().verifyWith(key).build().parseSignedClaims(authToken).getPayload(); } + public T getClaim(String token, String claimName, Class claimType) { + Claims claims = parseClaims(token); + return claims.get(claimName, claimType); + } + public Date getExpirationDate(String authToken) { return parseClaims(authToken).getExpiration(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java index 16c94629047c..fae3822f08cf 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java @@ -34,6 +34,8 @@ import de.tum.cit.aet.artemis.core.exception.EmailAlreadyUsedException; import de.tum.cit.aet.artemis.core.exception.PasswordViolatesRequirementsException; import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.allowedTools.AllowedTools; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.service.AccountService; import de.tum.cit.aet.artemis.core.service.FilePathService; @@ -172,6 +174,7 @@ public ResponseEntity deleteVcsAccessToken() { */ @GetMapping("account/participation-vcs-access-token") @EnforceAtLeastStudent + @AllowedTools(ToolTokenType.SCORPIO) public ResponseEntity getVcsAccessToken(@RequestParam("participationId") Long participationId) { User user = userRepository.getUser(); @@ -188,6 +191,7 @@ public ResponseEntity getVcsAccessToken(@RequestParam("participationId") */ @PutMapping("account/participation-vcs-access-token") @EnforceAtLeastStudent + @AllowedTools(ToolTokenType.SCORPIO) public ResponseEntity createVcsAccessToken(@RequestParam("participationId") Long participationId) { User user = userRepository.getUser(); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java index 9880ba867b40..495cbff77d7b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java @@ -94,6 +94,8 @@ import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; +import de.tum.cit.aet.artemis.core.security.allowedTools.AllowedTools; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastEditor; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; @@ -590,6 +592,7 @@ public ResponseEntity> getCoursesForEnrollment() { // TODO: we should rename this into courses/{courseId}/details @GetMapping("courses/{courseId}/for-dashboard") @EnforceAtLeastStudent + @AllowedTools(ToolTokenType.SCORPIO) public ResponseEntity getCourseForDashboard(@PathVariable long courseId) { long timeNanoStart = System.nanoTime(); log.debug("REST request to get one course {} with exams, lectures, exercises, participations, submissions and results, etc.", courseId); @@ -654,6 +657,7 @@ public record CourseDropdownDTO(Long id, String title, String courseIcon) { */ @GetMapping("courses/for-dashboard") @EnforceAtLeastStudent + @AllowedTools(ToolTokenType.SCORPIO) public ResponseEntity getCoursesForDashboard() { long timeNanoStart = System.nanoTime(); User user = userRepository.getUserWithGroupsAndAuthorities(); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/TokenResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/TokenResource.java new file mode 100644 index 000000000000..54b3ad20a07c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/TokenResource.java @@ -0,0 +1,71 @@ +package de.tum.cit.aet.artemis.core.web; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.Duration; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; +import de.tum.cit.aet.artemis.core.security.jwt.JWTCookieService; +import de.tum.cit.aet.artemis.core.security.jwt.JWTFilter; +import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider; + +@Profile(PROFILE_CORE) +@RestController +@RequestMapping("api/") +public class TokenResource { + + private final JWTCookieService jwtCookieService; + + private final TokenProvider tokenProvider; + + public TokenResource(JWTCookieService jwtCookieService, TokenProvider tokenProvider) { + this.jwtCookieService = jwtCookieService; + this.tokenProvider = tokenProvider; + } + + /** + * Sends a tool token back as cookie or bearer token + * + * @param tool the tool for which the token is requested + * @param asCookie if true the token is sent back as a cookie + * @param request HTTP request + * @param response HTTP response + * @return the ResponseEntity with status 200 (ok), 401 (unauthorized) and depending on the asCookie flag a bearer token in the body + */ + @PostMapping("tool-token") + @EnforceAtLeastStudent + public ResponseEntity convertCookieToToolToken(@RequestParam(name = "tool", required = true) ToolTokenType tool, + @RequestParam(name = "as-cookie", defaultValue = "false") boolean asCookie, HttpServletRequest request, HttpServletResponse response) { + // remaining time in milliseconds + var jwtToken = JWTFilter.extractValidJwt(request, tokenProvider); + if (jwtToken == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + // get validity of the token + long tokenRemainingTime = tokenProvider.getExpirationDate(jwtToken).getTime() - System.currentTimeMillis(); + + // 1 day validity + long maxDuration = Duration.ofDays(1).toMillis(); + ResponseCookie responseCookie = jwtCookieService.buildLoginCookie(Math.min(tokenRemainingTime, maxDuration), tool); + + if (asCookie) { + response.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString()); + } + return ResponseEntity.ok(responseCookie.getValue()); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicUserJwtResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicUserJwtResource.java index 90020572f571..d2a9f762551d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicUserJwtResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicUserJwtResource.java @@ -28,12 +28,14 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import de.tum.cit.aet.artemis.core.dto.vm.LoginVM; import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; import de.tum.cit.aet.artemis.core.security.SecurityUtils; import de.tum.cit.aet.artemis.core.security.UserNotActivatedException; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; import de.tum.cit.aet.artemis.core.security.annotations.EnforceNothing; import de.tum.cit.aet.artemis.core.security.jwt.JWTCookieService; import de.tum.cit.aet.artemis.core.service.connectors.SAML2Service; @@ -65,12 +67,14 @@ public PublicUserJwtResource(JWTCookieService jwtCookieService, AuthenticationMa * * @param loginVM user credentials View Mode * @param userAgent User Agent + * @param tool optional Tool Token Type to define the scope of the token * @param response HTTP response * @return the ResponseEntity with status 200 (ok), 401 (unauthorized) or 403 (Captcha required) */ @PostMapping("authenticate") @EnforceNothing - public ResponseEntity> authorize(@Valid @RequestBody LoginVM loginVM, @RequestHeader("User-Agent") String userAgent, HttpServletResponse response) { + public ResponseEntity> authorize(@Valid @RequestBody LoginVM loginVM, @RequestHeader("User-Agent") String userAgent, + @RequestParam(name = "tool", required = false) ToolTokenType tool, HttpServletResponse response) { var username = loginVM.getUsername(); var password = loginVM.getPassword(); @@ -84,7 +88,7 @@ public ResponseEntity> authorize(@Valid @RequestBody LoginVM SecurityContextHolder.getContext().setAuthentication(authentication); boolean rememberMe = loginVM.isRememberMe() != null && loginVM.isRememberMe(); - ResponseCookie responseCookie = jwtCookieService.buildLoginCookie(rememberMe); + ResponseCookie responseCookie = jwtCookieService.buildLoginCookie(rememberMe, tool); response.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString()); return ResponseEntity.ok(Map.of("access_token", responseCookie.getValue())); @@ -127,7 +131,7 @@ public ResponseEntity authorizeSAML2(@RequestBody final String body, HttpS } final boolean rememberMe = Boolean.parseBoolean(body); - ResponseCookie responseCookie = jwtCookieService.buildLoginCookie(rememberMe); + ResponseCookie responseCookie = jwtCookieService.buildLoginCookie(rememberMe, null); response.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString()); return ResponseEntity.ok().build(); diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java index 3a8c0b6368b9..14ab3e8da46d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java @@ -55,6 +55,8 @@ import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; +import de.tum.cit.aet.artemis.core.security.allowedTools.AllowedTools; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastTutor; @@ -218,6 +220,7 @@ public ParticipationResource(ParticipationService participationService, Programm */ @PostMapping("exercises/{exerciseId}/participations") @EnforceAtLeastStudentInExercise + @AllowedTools(ToolTokenType.SCORPIO) public ResponseEntity startParticipation(@PathVariable Long exerciseId) throws URISyntaxException { log.debug("REST request to start Exercise : {}", exerciseId); Exercise exercise = exerciseRepository.findByIdElseThrow(exerciseId); @@ -777,6 +780,7 @@ public ResponseEntity getParticipationBuildArtifact(@PathVariable Long p */ @GetMapping("exercises/{exerciseId}/participation") @EnforceAtLeastStudent + @AllowedTools(ToolTokenType.SCORPIO) public ResponseEntity getParticipationForCurrentUser(@PathVariable Long exerciseId, Principal principal) { log.debug("REST request to get Participation for Exercise : {}", exerciseId); Exercise exercise = exerciseRepository.findByIdElseThrow(exerciseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationTeamWebsocketService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationTeamWebsocketService.java index ad7d9b0fa16a..acc8727f17ea 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationTeamWebsocketService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationTeamWebsocketService.java @@ -184,7 +184,7 @@ public void patchModelingSubmission(@DestinationVariable Long participationId, @ public void updateTextSubmission(@DestinationVariable Long participationId, @Payload TextSubmission textSubmission, Principal principal) { long start = System.currentTimeMillis(); updateSubmission(participationId, textSubmission, principal, "/text-submissions", true); - log.info("Websocket endpoint updateTextSubmission took {}ms for submission with id {}", System.currentTimeMillis() - start, textSubmission.getId()); + log.debug("Websocket endpoint updateTextSubmission took {}ms for submission with id {}", System.currentTimeMillis() - start, textSubmission.getId()); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/PlantUmlResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/PlantUmlResource.java index e110c48d6cbc..c0b667eb81cc 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/PlantUmlResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/PlantUmlResource.java @@ -17,6 +17,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import de.tum.cit.aet.artemis.core.security.allowedTools.AllowedTools; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.programming.service.PlantUmlService; @@ -63,6 +65,7 @@ public ResponseEntity generatePng(@RequestParam("plantuml") String plant */ @GetMapping("svg") @EnforceAtLeastStudent + @AllowedTools(ToolTokenType.SCORPIO) public ResponseEntity generateSvg(@RequestParam("plantuml") String plantuml, @RequestParam(value = "useDarkTheme", defaultValue = "false") boolean useDarkTheme) throws IOException { long start = System.nanoTime(); diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizMessagingService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizMessagingService.java index 0c5900072048..0382b523df6f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizMessagingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizMessagingService.java @@ -51,6 +51,7 @@ public void sendQuizExerciseToSubscribedClients(QuizExercise quizExercise, @Null try { long start = System.currentTimeMillis(); Class view = quizExercise.viewForStudentsInQuizExercise(quizBatch); + // TODO: use a proper DTO and avoid sending the whole quiz exercise based on the view byte[] payload = objectMapper.writerWithView(view).writeValueAsBytes(quizExercise); // For each change we send the same message. The client needs to decide how to handle the date based on the quiz status if (quizExercise.isVisibleToStudents() && quizExercise.isCourseExercise()) { @@ -63,6 +64,7 @@ public void sendQuizExerciseToSubscribedClients(QuizExercise quizExercise, @Null if (quizChange == START_BATCH && quizBatch != null) { destination = destination + "/" + quizBatch.getId(); } + // TODO the view could also be passed as conversion hint to the message converter websocketMessagingService.sendMessage(destination, MessageBuilder.withPayload(payload).build()); log.info("Sent '{}' for quiz {} to all listening clients in {} ms", quizChange, quizExercise.getId(), System.currentTimeMillis() - start); } diff --git a/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseFeedbackService.java b/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseFeedbackService.java index d95a76755e6c..0a874e45b8f8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseFeedbackService.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/service/TextExerciseFeedbackService.java @@ -121,7 +121,7 @@ public void generateAutomaticNonGradedFeedback(StudentParticipation participatio log.debug("Submission id: {}", textSubmission.getId()); - var athenaResponse = this.athenaFeedbackSuggestionsService.orElseThrow().getTextFeedbackSuggestions(textExercise, textSubmission, true); + var athenaResponse = this.athenaFeedbackSuggestionsService.orElseThrow().getTextFeedbackSuggestions(textExercise, textSubmission, false); Set textBlocks = new HashSet<>(); List feedbacks = new ArrayList<>(); diff --git a/src/main/webapp/app/assessment/unreferenced-feedback-detail/unreferenced-feedback-detail.component.ts b/src/main/webapp/app/assessment/unreferenced-feedback-detail/unreferenced-feedback-detail.component.ts index 483eebaf28f9..46f98ec434c7 100644 --- a/src/main/webapp/app/assessment/unreferenced-feedback-detail/unreferenced-feedback-detail.component.ts +++ b/src/main/webapp/app/assessment/unreferenced-feedback-detail/unreferenced-feedback-detail.component.ts @@ -101,6 +101,7 @@ export class UnreferencedFeedbackDetailComponent implements OnInit { } updateFeedbackOnDrop(event: Event) { + event.stopPropagation(); this.structuredGradingCriterionService.updateFeedbackWithStructuredGradingInstructionEvent(this.feedback, event); this.onFeedbackChange.emit(this.feedback); } diff --git a/src/main/webapp/app/core/auth/account.service.ts b/src/main/webapp/app/core/auth/account.service.ts index 8745ae5357b9..8cc91bc9e586 100644 --- a/src/main/webapp/app/core/auth/account.service.ts +++ b/src/main/webapp/app/core/auth/account.service.ts @@ -5,7 +5,7 @@ import { BehaviorSubject, Observable, lastValueFrom, of } from 'rxjs'; import { catchError, distinctUntilChanged, map } from 'rxjs/operators'; import { Course } from 'app/entities/course.model'; import { User } from 'app/core/user/user.model'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { FeatureToggleService } from 'app/shared/feature-toggle/feature-toggle.service'; import { setUser } from '@sentry/angular'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; @@ -37,7 +37,7 @@ export class AccountService implements IAccountService { private translateService = inject(TranslateService); private sessionStorage = inject(SessionStorageService); private http = inject(HttpClient); - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private featureToggleService = inject(FeatureToggleService); // cached value of the user to avoid unnecessary requests to the server diff --git a/src/main/webapp/app/core/websocket/websocket.service.ts b/src/main/webapp/app/core/websocket/websocket.service.ts index 3d99a958f140..3b70c0451dbe 100644 --- a/src/main/webapp/app/core/websocket/websocket.service.ts +++ b/src/main/webapp/app/core/websocket/websocket.service.ts @@ -1,7 +1,19 @@ import { Injectable, OnDestroy } from '@angular/core'; import { BehaviorSubject, Observable, Subscriber, Subscription, first } from 'rxjs'; import SockJS from 'sockjs-client'; -import Stomp, { Client, ConnectionHeaders, Subscription as StompSubscription } from 'webstomp-client'; +import Stomp, { Client, ConnectionHeaders, Message, Subscription as StompSubscription } from 'webstomp-client'; +import { gzip, ungzip } from 'pako'; +import { captureException } from '@sentry/angular'; + +interface SockJSExtended extends WebSocket { + _transport?: { + url?: string; + }; +} + +// must be the same as in GzipMessageConverter.java +export const COMPRESSION_HEADER_KEY = 'X-Compressed'; +export const COMPRESSION_HEADER: Record = { [COMPRESSION_HEADER_KEY]: 'true' }; export interface IWebsocketService { /** @@ -27,20 +39,20 @@ export interface IWebsocketService { /** * Send data through the websocket connection - * @param path {string} the path for the websocket connection - * @param data {object} the data to send through the websocket connection + * @param path the path for the websocket connection + * @param data the data to send through the websocket connection */ - send(path: string, data: any): void; + send(path: string, data: T): void; /** * Subscribe to a channel. - * @param channel + * @param channel topic */ subscribe(channel: string): IWebsocketService; /** * Unsubscribe a channel. - * @param channel + * @param channel topic */ unsubscribe(channel: string): void; @@ -76,7 +88,7 @@ export class ConnectionState { * Server <1--1> Stomp <1--1> websocket.service.ts <1--n*m> Angular components * channel topic */ @Injectable({ providedIn: 'root' }) -export class JhiWebsocketService implements IWebsocketService, OnDestroy { +export class WebsocketService implements IWebsocketService, OnDestroy { private stompClient?: Client; // we store the STOMP subscriptions per channel so that we can unsubscribe in case we are not interested any more @@ -93,7 +105,7 @@ export class JhiWebsocketService implements IWebsocketService, OnDestroy { private readonly connectionStateInternal: BehaviorSubject; private consecutiveFailedAttempts = 0; private connecting = false; - private socket: any = undefined; + private socket: SockJSExtended | undefined = undefined; private subscriptionCounter = 0; constructor() { @@ -161,10 +173,11 @@ export class JhiWebsocketService implements IWebsocketService, OnDestroy { debug: false, protocols: ['v12.stomp'], }; + // TODO: consider to switch to RxStomp (like in the latest jhipster version) this.stompClient = Stomp.over(this.socket, options); // Note: at the moment, debugging is deactivated to prevent console log statements this.stompClient.debug = () => {}; - const headers = {}; + const headers = {} as ConnectionHeaders; this.stompClient.connect( headers, @@ -177,7 +190,7 @@ export class JhiWebsocketService implements IWebsocketService, OnDestroy { if (this.alreadyConnectedOnce) { // (re)connect to all existing channels if (this.observables.size !== 0) { - this.observables.forEach((observable, channel) => this.addSubscription(channel)); + this.observables.forEach((_observable, channel) => this.addSubscription(channel)); } } else { this.alreadyConnectedOnce = true; @@ -192,22 +205,89 @@ export class JhiWebsocketService implements IWebsocketService, OnDestroy { * @param channel the path (e.g. '/courses/5/exercises/10') that should be subscribed */ private addSubscription(channel: string) { - const subscription = this.stompClient!.subscribe( - channel, - (message) => { - // this code is invoked if a new websocket message was received from the server - // we pass the message to the subscriber (e.g. a component who will be notified and can handle the message) - if (this.subscribers.has(channel)) { - this.subscribers.get(channel)!.next(JhiWebsocketService.parseJSON(message.body)); + const subscription = this.stompClient!.subscribe(channel, this.handleIncomingMessage(channel), { + id: this.getSessionId() + '-' + this.subscriptionCounter++, + }); + this.stompSubscriptions.set(channel, subscription); + } + + /** + * Handle incoming messages from the server, which are potentially compressed: + * 1. Decode the Base64 string to binary data + * 2. Decompress the binary data to a string payload (JSON) + * 3. Parse the JSON payload and pass it to the subscribers + * @param channel the channel the message was received on + */ + private handleIncomingMessage(channel: string) { + return (message: Message) => { + // this code is invoked if a new websocket message was received from the server + // we pass the message to the subscriber (e.g. a component who will be notified and can handle the message) + if (this.subscribers.has(channel)) { + const isCompressed = message.headers[COMPRESSION_HEADER_KEY] === 'true'; + let payload = message.body; + + if (isCompressed) { + try { + payload = WebsocketService.decodeAndDecompress(payload); + } catch (error) { + captureException('Failed to decompress message', error); + } } - }, - { - id: this.getSessionId() + '-' + this.subscriptionCounter++, - }, + + this.subscribers.get(channel)!.next(WebsocketService.parseJSON(payload)); + } + }; + } + + /** + * Compresses a given string payload using GZIP and encodes the compressed data into a Base64 string. + * + *

This method performs the following steps: + *

    + *
  1. Compresses the input string using GZIP.
  2. + *
  3. Converts the compressed binary data into a Base64-encoded string.
  4. + *
+ * + * @param payload The string payload to be compressed and encoded. + * @returns A Base64-encoded string representing the compressed payload. + * @throws Error If compression or Base64 encoding fails. + */ + private static compressAndEncode(payload: string): string { + // 1. Compress if larger than 1 KB + const compressedPayload = gzip(payload); + // 2. Convert binary data to base64 string + return window.btoa( + Array.from(compressedPayload) + .map((byte) => String.fromCharCode(byte)) + .join(''), ); - this.stompSubscriptions.set(channel, subscription); } + /** + * Decodes a Base64-encoded string and decompresses the resulting binary data using GZIP. + * + *

This method performs the following steps: + *

    + *
  1. Decodes the Base64-encoded string into binary data.
  2. + *
  3. Decompresses the binary data using GZIP.
  4. + *
+ * + * @param payload The Base64-encoded string representing compressed data. + * @returns The decompressed string. + * @throws Error If decoding or decompression fails. + */ + private static decodeAndDecompress(payload: string): string { + // 1. Decode the Base64 string to binary (ArrayBuffer) and convert to Uint8Array + const binaryData = Uint8Array.from(window.atob(payload), (char) => char.charCodeAt(0)); + // 2. Decompress using pako + return ungzip(binaryData, { to: 'string' }); + } + + /** + * Checks whether the WebSocket connection is currently established. + * + * @returns true if the WebSocket connection is active; otherwise, false. + */ public isConnected(): boolean { return this.stompClient?.connected || false; } @@ -216,7 +296,7 @@ export class JhiWebsocketService implements IWebsocketService, OnDestroy { * Close the connection to the websocket (e.g. due to logout), unsubscribe all observables and set alreadyConnectedOnce to false */ disconnect() { - this.observables.forEach((observable, channel) => this.unsubscribe(channel)); + this.observables.forEach((_observable, channel) => this.unsubscribe(channel)); this.waitUntilConnectionSubscriptions.forEach((subscription) => subscription.unsubscribe()); if (this.stompClient) { this.stompClient.disconnect(); @@ -241,13 +321,33 @@ export class JhiWebsocketService implements IWebsocketService, OnDestroy { } /** - * Send data through the websocket connection - * @param path {string} the path for the websocket connection - * @param data {object} the data to send through the websocket connection + * Send data through the websocket connection, potentially compressing the payload. + * Only compresses data if the JSON stringified payload size is larger than 1 KB. + * 1. Convert the data into JSON + * 2. Compress the JSON payload into binary data if it is larger than 1 KB + * 3. Convert the binary data into a Base64 string + * + * @param path the path for the websocket connection + * @param data the data to send through the websocket connection */ - send(path: string, data: any): void { + send(path: string, data: T): void { if (this.isConnected()) { - this.stompClient!.send(path, JSON.stringify(data), {}); + const jsonPayload = JSON.stringify(data); + const payloadSize = new Blob([jsonPayload]).size; // Measure payload size + + if (payloadSize > 1024) { + try { + const base64StringPayload = WebsocketService.compressAndEncode(jsonPayload); + this.stompClient!.send(path, base64StringPayload, COMPRESSION_HEADER); + } catch (error) { + captureException('Failed to compress websocket message', error); + // Send uncompressed payload if an error occurs + this.stompClient!.send(path, jsonPayload, {}); + } + } else { + // Send uncompressed payload + this.stompClient!.send(path, jsonPayload, {}); + } } } @@ -272,8 +372,8 @@ export class JhiWebsocketService implements IWebsocketService, OnDestroy { } /** - * Unsubscribe a channel. - * @param channel + * Unsubscribe a channel if the component is not interested in the messages anymore + * @param channel topic for which the component wants to unsubscribe */ unsubscribe(channel: string) { if (this && this.stompSubscriptions && this.stompSubscriptions.has(channel)) { @@ -322,20 +422,30 @@ export class JhiWebsocketService implements IWebsocketService, OnDestroy { this.disconnect(); } - private static parseJSON(response: string): any { + /** + * Parses a JSON string into an object of the specified generic type. + * + *

This method attempts to parse the provided JSON string. If parsing fails, + * it returns the input string cast to the specified type. This can be useful + * for handling cases where the response might not always be a valid JSON string.

+ * + * @param response The JSON string to be parsed. + * @returns The parsed object of the specified type, or the input string cast to the type if parsing fails. + * @template T The type of the object to return after parsing. + * @throws Error If JSON parsing fails and the input is not a valid string cast to the specified type. + */ + private static parseJSON(response: string): T { try { return JSON.parse(response); } catch { - return response; + return response as T; } } - // https://stackoverflow.com/a/35651029/3802758 + // see https://stackoverflow.com/a/35651029/3802758 private getSessionId(): string { - if (this.socket && this.socket._transport && this.socket._transport.url) { - return this.socket._transport.url.match('.*\\/websocket\\/\\d*\\/(.*)\\/websocket.*')[1]; - } else { - return 'unsubscribed'; - } + const url = this.socket?._transport?.url; + const match = url?.match('.*\\/websocket\\/\\d*\\/(.*)\\/websocket.*'); + return match ? match[1] : 'unsubscribed'; } } diff --git a/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts b/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts index 65256d6ed5b8..4525a2ad3536 100644 --- a/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts +++ b/src/main/webapp/app/course/competencies/generate-competencies/generate-competencies.component.ts @@ -15,7 +15,7 @@ import { Observable, firstValueFrom, map } from 'rxjs'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { TranslateService } from '@ngx-translate/core'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { IrisStageDTO, IrisStageStateDTO } from 'app/entities/iris/iris-stage-dto.model'; import { CourseCompetencyService } from 'app/course/competencies/course-competency.service'; import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; @@ -62,7 +62,7 @@ export class GenerateCompetenciesComponent implements OnInit, ComponentCanDeacti private modalService = inject(NgbModal); private artemisTranslatePipe = inject(ArtemisTranslatePipe); private translateService = inject(TranslateService); - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); @ViewChild(CourseDescriptionFormComponent) courseDescriptionForm: CourseDescriptionFormComponent; diff --git a/src/main/webapp/app/exam/manage/exam-status.component.ts b/src/main/webapp/app/exam/manage/exam-status.component.ts index 3becf156aa5a..1c0a38b4aed7 100644 --- a/src/main/webapp/app/exam/manage/exam-status.component.ts +++ b/src/main/webapp/app/exam/manage/exam-status.component.ts @@ -6,7 +6,7 @@ import { ExamChecklist } from 'app/entities/exam/exam-checklist.model'; import dayjs from 'dayjs/esm'; import { round } from 'app/shared/util/utils'; import { Course } from 'app/entities/course.model'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { NgClass } from '@angular/common'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { TranslateDirective } from 'app/shared/language/translate.directive'; @@ -36,7 +36,7 @@ export enum ExamConductionState { }) export class ExamStatusComponent implements OnChanges, OnInit, OnDestroy { private examChecklistService = inject(ExamChecklistService); - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); @Input() public exam: Exam; diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.ts b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.ts index 88e6412574a4..4f29b11592ca 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.ts +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.ts @@ -3,7 +3,7 @@ import { Exam } from 'app/entities/exam/exam.model'; import { ExamChecklist } from 'app/entities/exam/exam-checklist.model'; import { faChartBar, faEye, faListAlt, faThList, faUser, faWrench } from '@fortawesome/free-solid-svg-icons'; import { ExamChecklistService } from 'app/exam/manage/exams/exam-checklist-component/exam-checklist.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { ExamManagementService } from 'app/exam/manage/exam-management.service'; import { AlertService } from 'app/core/util/alert.service'; import { HttpErrorResponse } from '@angular/common/http'; @@ -40,7 +40,7 @@ import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; }) export class ExamChecklistComponent implements OnChanges, OnInit, OnDestroy { private examChecklistService = inject(ExamChecklistService); - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private examManagementService = inject(ExamManagementService); private alertService = inject(AlertService); private studentExamService = inject(StudentExamService); diff --git a/src/main/webapp/app/exam/manage/student-exams/student-exams.component.ts b/src/main/webapp/app/exam/manage/student-exams/student-exams.component.ts index e1f30113fd9b..abe4efef4c3d 100644 --- a/src/main/webapp/app/exam/manage/student-exams/student-exams.component.ts +++ b/src/main/webapp/app/exam/manage/student-exams/student-exams.component.ts @@ -19,7 +19,7 @@ import { AccountService } from 'app/core/auth/account.service'; import { onError } from 'app/shared/util/global.utils'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { convertDateFromServer } from 'app/utils/date.utils'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { PROFILE_LOCALVC } from 'app/app.constants'; @@ -66,7 +66,7 @@ export class StudentExamsComponent implements OnInit, OnDestroy { private modalService = inject(NgbModal); private accountService = inject(AccountService); private artemisTranslatePipe = inject(ArtemisTranslatePipe); - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private profileService = inject(ProfileService); courseId: number; diff --git a/src/main/webapp/app/exam/participate/exam-participation-live-events.service.ts b/src/main/webapp/app/exam/participate/exam-participation-live-events.service.ts index 1058fb8fbbba..3daa6a3e2d6f 100644 --- a/src/main/webapp/app/exam/participate/exam-participation-live-events.service.ts +++ b/src/main/webapp/app/exam/participate/exam-participation-live-events.service.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { ConnectionState, JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { ConnectionState, WebsocketService } from 'app/core/websocket/websocket.service'; import { ExamParticipationService } from 'app/exam/participate/exam-participation.service'; import dayjs from 'dayjs/esm'; import { LocalStorageService } from 'ngx-webstorage'; @@ -54,7 +54,7 @@ export type ProblemStatementUpdateEvent = ExamLiveEvent & { @Injectable({ providedIn: 'root' }) export class ExamParticipationLiveEventsService { - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private examParticipationService = inject(ExamParticipationService); private localStorageService = inject(LocalStorageService); private httpClient = inject(HttpClient); diff --git a/src/main/webapp/app/exam/participate/exam-participation.component.ts b/src/main/webapp/app/exam/participate/exam-participation.component.ts index c4b8c658aff2..69dde679bdbb 100644 --- a/src/main/webapp/app/exam/participate/exam-participation.component.ts +++ b/src/main/webapp/app/exam/participate/exam-participation.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit, QueryList, ViewChildren, inject } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { ExamParticipationService } from 'app/exam/participate/exam-participation.service'; import { StudentExam } from 'app/entities/student-exam.model'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; @@ -90,7 +90,7 @@ type GenerateParticipationStatus = 'generating' | 'failed' | 'success'; ], }) export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentCanDeactivate { - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private route = inject(ActivatedRoute); private router = inject(Router); private examParticipationService = inject(ExamParticipationService); diff --git a/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.ts b/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.ts index e0826f6413d0..09b504dcb669 100644 --- a/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.ts +++ b/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.ts @@ -2,7 +2,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Component, Input, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { Patch, Selection, UMLDiagramType, UMLElementType, UMLModel, UMLRelationshipType } from '@ls1intum/apollon'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { ComplaintType } from 'app/entities/complaint.model'; import { Feedback, buildFeedbackTextForReview, checkSubsequentFeedbackInAssessment } from 'app/entities/feedback.model'; import { ModelingExercise } from 'app/entities/modeling-exercise.model'; @@ -87,7 +87,7 @@ import { captureException } from '@sentry/angular'; ], }) export class ModelingSubmissionComponent implements OnInit, OnDestroy, ComponentCanDeactivate { - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private modelingSubmissionService = inject(ModelingSubmissionService); private modelingAssessmentService = inject(ModelingAssessmentService); private alertService = inject(AlertService); diff --git a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-grading.service.ts b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-grading.service.ts index 9150326c5570..7d573cd15061 100644 --- a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-grading.service.ts +++ b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-grading.service.ts @@ -2,7 +2,7 @@ import { Injectable, OnDestroy, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { catchError, map, switchMap, tap } from 'rxjs/operators'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { ProgrammingExerciseTestCase, Visibility } from 'app/entities/programming/programming-exercise-test-case.model'; import { StaticCodeAnalysisCategory } from 'app/entities/programming/static-code-analysis-category.model'; import { ProgrammingExerciseGradingStatistics } from 'app/entities/programming/programming-exercise-test-case-statistics.model'; @@ -47,7 +47,7 @@ export interface IProgrammingExerciseGradingService { @Injectable({ providedIn: 'root' }) export class ProgrammingExerciseGradingService implements IProgrammingExerciseGradingService, OnDestroy { - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private http = inject(HttpClient); public resourceUrl = 'api/programming-exercises'; diff --git a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-websocket.service.ts b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-websocket.service.ts index 125d0c0397a2..b59ba39a887e 100644 --- a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-websocket.service.ts +++ b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-websocket.service.ts @@ -2,7 +2,7 @@ import { Injectable, OnDestroy, inject } from '@angular/core'; import { HttpResponse } from '@angular/common/http'; import { BehaviorSubject, Observable } from 'rxjs'; import { filter, tap } from 'rxjs/operators'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; export type EntityResponseType = HttpResponse; @@ -20,7 +20,7 @@ export interface IProgrammingExerciseWebsocketService { @Injectable({ providedIn: 'root' }) export class ProgrammingExerciseWebsocketService implements OnDestroy, IProgrammingExerciseWebsocketService { - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private connections: string[] = []; // Uses undefined for initial value. diff --git a/src/main/webapp/app/exercises/programming/participate/programming-build-run.service.ts b/src/main/webapp/app/exercises/programming/participate/programming-build-run.service.ts index 88ef2c010196..80a836ce4005 100644 --- a/src/main/webapp/app/exercises/programming/participate/programming-build-run.service.ts +++ b/src/main/webapp/app/exercises/programming/participate/programming-build-run.service.ts @@ -1,7 +1,7 @@ import { Injectable, OnDestroy, inject } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { filter, tap } from 'rxjs/operators'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; /** * Describes the build run state @@ -24,7 +24,7 @@ export interface IProgrammingBuildRunService { */ @Injectable({ providedIn: 'root' }) export class ProgrammingBuildRunService implements OnDestroy { - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); // Boolean subject: true == build is running, false == build is not running. private buildRunSubjects: { [programmingExerciseId: number]: BehaviorSubject } = {}; diff --git a/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts b/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts index 655c4d7dab6b..48812dfd7fae 100644 --- a/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts +++ b/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts @@ -5,7 +5,7 @@ import { catchError, distinctUntilChanged, filter, map, reduce, switchMap, tap } import { ParticipationWebsocketService } from 'app/overview/participation-websocket.service'; import { Result } from 'app/entities/result.model'; import { createRequestOption } from 'app/shared/util/request.util'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { ProgrammingSubmission } from 'app/entities/programming/programming-submission.model'; import { SubmissionType, getLatestSubmissionResult, setLatestSubmissionResult } from 'app/entities/submission.model'; @@ -66,7 +66,7 @@ export interface IProgrammingSubmissionService { @Injectable({ providedIn: 'root' }) export class ProgrammingSubmissionService implements IProgrammingSubmissionService, OnDestroy { - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private http = inject(HttpClient); private participationWebsocketService = inject(ParticipationWebsocketService); private participationService = inject(ProgrammingExerciseParticipationService); diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts index e3429e7e4901..0c991b5b07c6 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-conflict-state.service.ts @@ -1,7 +1,7 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { Injectable, OnDestroy, inject } from '@angular/core'; import { distinctUntilChanged } from 'rxjs/operators'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { DomainType, GitConflictState } from 'app/exercises/programming/shared/code-editor/model/code-editor.model'; import { DomainDependentService } from 'app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent.service'; @@ -16,7 +16,7 @@ export interface IConflictStateService { */ @Injectable({ providedIn: 'root' }) export class CodeEditorConflictStateService extends DomainDependentService implements IConflictStateService, OnDestroy { - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private conflictSubjects: Map> = new Map(); private websocketConnections: Map = new Map(); diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts index bd34fcd567a0..4fd362b3d137 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent-endpoint.service.ts @@ -1,6 +1,6 @@ import { DomainDependentService } from 'app/exercises/programming/shared/code-editor/service/code-editor-domain-dependent.service'; import { HttpClient } from '@angular/common/http'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { DomainChange, DomainType } from 'app/exercises/programming/shared/code-editor/model/code-editor.model'; import { inject } from '@angular/core'; @@ -10,7 +10,7 @@ import { inject } from '@angular/core'; export abstract class DomainDependentEndpointService extends DomainDependentService { protected restResourceUrl?: string; protected http = inject(HttpClient); - protected jhiWebsocketService = inject(JhiWebsocketService); + protected jhiWebsocketService = inject(WebsocketService); protected constructor() { super(); diff --git a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts index 15f2a2d2459a..f27804094982 100644 --- a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts @@ -1000,7 +1000,7 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte * @returns returns a blob created from the data url */ dataUrlToBlob(dataUrl: string): Blob { - // Seperate metadata from base64-encoded content + // Separate metadata from base64-encoded content const byteString = window.atob(dataUrl.split(',')[1]); // Isolate the MIME type (e.g "image/png") const mimeString = dataUrl.split(',')[0].split(':')[1].split(';')[0]; diff --git a/src/main/webapp/app/exercises/quiz/manage/statistics/question-statistic.component.ts b/src/main/webapp/app/exercises/quiz/manage/statistics/question-statistic.component.ts index 7b79e4dfb324..cb70f6d4af3b 100644 --- a/src/main/webapp/app/exercises/quiz/manage/statistics/question-statistic.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/statistics/question-statistic.component.ts @@ -3,7 +3,7 @@ import { QuizQuestionStatistic } from 'app/entities/quiz/quiz-question-statistic import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; import { AccountService } from 'app/core/auth/account.service'; import { QuizExerciseService } from 'app/exercises/quiz/manage/quiz-exercise.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { Authority } from 'app/shared/constants/authority.constants'; import { Subscription } from 'rxjs'; import { SafeHtml } from '@angular/platform-browser'; @@ -26,7 +26,7 @@ export abstract class QuestionStatisticComponent extends AbstractQuizStatisticCo protected router = inject(Router); protected accountService = inject(AccountService); protected quizExerciseService = inject(QuizExerciseService); - protected jhiWebsocketService = inject(JhiWebsocketService); + protected jhiWebsocketService = inject(WebsocketService); protected changeDetector = inject(ChangeDetectorRef); question: QuizQuestion; diff --git a/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-point-statistic/quiz-point-statistic.component.ts b/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-point-statistic/quiz-point-statistic.component.ts index bb31e1baee65..008c03884dd6 100644 --- a/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-point-statistic/quiz-point-statistic.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-point-statistic/quiz-point-statistic.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angula import { ActivatedRoute, Router } from '@angular/router'; import { AbstractQuizStatisticComponent } from 'app/exercises/quiz/manage/statistics/quiz-statistics'; import { AccountService } from 'app/core/auth/account.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { PointCounter } from 'app/entities/quiz/point-counter.model'; import { QuizExerciseService } from 'app/exercises/quiz/manage/quiz-exercise.service'; import { QuizPointStatistic } from 'app/entities/quiz/quiz-point-statistic.model'; @@ -31,7 +31,7 @@ export class QuizPointStatisticComponent extends AbstractQuizStatisticComponent private router = inject(Router); private accountService = inject(AccountService); private quizExerciseService = inject(QuizExerciseService); - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private changeDetector = inject(ChangeDetectorRef); private serverDateService = inject(ArtemisServerDateService); diff --git a/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-statistic/quiz-statistic.component.ts b/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-statistic/quiz-statistic.component.ts index 45b010a9e8df..130bcbd51bb6 100644 --- a/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-statistic/quiz-statistic.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-statistic/quiz-statistic.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angula import { ActivatedRoute, Router } from '@angular/router'; import { HttpResponse } from '@angular/common/http'; import { AccountService } from 'app/core/auth/account.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; import { QuizExerciseService } from 'app/exercises/quiz/manage/quiz-exercise.service'; import { Authority } from 'app/shared/constants/authority.constants'; @@ -26,7 +26,7 @@ export class QuizStatisticComponent extends AbstractQuizStatisticComponent imple private router = inject(Router); private accountService = inject(AccountService); private quizExerciseService = inject(QuizExerciseService); - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private changeDetector = inject(ChangeDetectorRef); quizExercise: QuizExercise; diff --git a/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-statistics-footer/quiz-statistics-footer.component.ts b/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-statistics-footer/quiz-statistics-footer.component.ts index e563711c2c49..e44308391ae9 100644 --- a/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-statistics-footer/quiz-statistics-footer.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/statistics/quiz-statistics-footer/quiz-statistics-footer.component.ts @@ -5,7 +5,7 @@ import { ShortAnswerQuestionUtil } from 'app/exercises/quiz/shared/short-answer- import { TranslateService } from '@ngx-translate/core'; import { HttpResponse } from '@angular/common/http'; import { AccountService } from 'app/core/auth/account.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { QuizQuestion, QuizQuestionType } from 'app/entities/quiz/quiz-question.model'; import { QuizExerciseService } from 'app/exercises/quiz/manage/quiz-exercise.service'; import { MultipleChoiceQuestionStatistic } from 'app/entities/quiz/multiple-choice-question-statistic.model'; @@ -36,7 +36,7 @@ export class QuizStatisticsFooterComponent implements OnInit, OnDestroy { private translateService = inject(TranslateService); private quizExerciseService = inject(QuizExerciseService); private quizStatisticUtil = inject(QuizStatisticUtil); - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private serverDateService = inject(ArtemisServerDateService); @Input() isQuizPointStatistic: boolean; diff --git a/src/main/webapp/app/exercises/quiz/participate/quiz-participation.component.ts b/src/main/webapp/app/exercises/quiz/participate/quiz-participation.component.ts index 560071f503f6..c08a35c2514e 100644 --- a/src/main/webapp/app/exercises/quiz/participate/quiz-participation.component.ts +++ b/src/main/webapp/app/exercises/quiz/participate/quiz-participation.component.ts @@ -14,7 +14,7 @@ import { TranslateService } from '@ngx-translate/core'; import * as smoothscroll from 'smoothscroll-polyfill'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { ShortAnswerSubmittedAnswer } from 'app/entities/quiz/short-answer-submitted-answer.model'; import { QuizExerciseService } from 'app/exercises/quiz/manage/quiz-exercise.service'; import { DragAndDropMapping } from 'app/entities/quiz/drag-and-drop-mapping.model'; @@ -70,7 +70,7 @@ import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; ], }) export class QuizParticipationComponent implements OnInit, OnDestroy { - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private quizExerciseService = inject(QuizExerciseService); private participationService = inject(ParticipationService); private route = inject(ActivatedRoute); diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.ts b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.ts index c2b80acd012e..130e05209f1e 100644 --- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.ts +++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.component.ts @@ -14,7 +14,7 @@ import { ModelingSubmissionElement } from 'app/exercises/shared/plagiarism/types import { TextSubmissionElement } from 'app/exercises/shared/plagiarism/types/text/TextSubmissionElement'; import { ProgrammingExerciseService } from 'app/exercises/programming/manage/services/programming-exercise.service'; import { PlagiarismOptions } from 'app/exercises/shared/plagiarism/types/PlagiarismOptions'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { tap } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { faChevronRight, faExclamationTriangle, faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; @@ -65,7 +65,7 @@ export class PlagiarismInspectorComponent implements OnInit { private modelingExerciseService = inject(ModelingExerciseService); private programmingExerciseService = inject(ProgrammingExerciseService); private textExerciseService = inject(TextExerciseService); - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private translateService = inject(TranslateService); private inspectorService = inject(PlagiarismInspectorService); private plagiarismCasesService = inject(PlagiarismCasesService); diff --git a/src/main/webapp/app/exercises/shared/team-submission-sync/team-submission-sync.component.ts b/src/main/webapp/app/exercises/shared/team-submission-sync/team-submission-sync.component.ts index 2a8d455dd6ff..14a6f23b0819 100644 --- a/src/main/webapp/app/exercises/shared/team-submission-sync/team-submission-sync.component.ts +++ b/src/main/webapp/app/exercises/shared/team-submission-sync/team-submission-sync.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; import { throttleTime } from 'rxjs/operators'; import { AlertService } from 'app/core/util/alert.service'; @@ -18,7 +18,7 @@ import { SubmissionPatchPayload, isSubmissionPatchPayload } from 'app/entities/s }) export class TeamSubmissionSyncComponent implements OnInit { private accountService = inject(AccountService); - private teamSubmissionWebsocketService = inject(JhiWebsocketService); + private teamSubmissionWebsocketService = inject(WebsocketService); private alertService = inject(AlertService); // Sync settings @@ -76,14 +76,14 @@ export class TeamSubmissionSyncComponent implements OnInit { submission.participation.exercise = undefined; submission.participation.submissions = []; } - this.teamSubmissionWebsocketService.send(this.buildWebsocketTopic('/update'), submission); + this.teamSubmissionWebsocketService.send(this.buildWebsocketTopic('/update'), submission); }, error: (error) => this.onError(error), }); this.submissionPatchObservable?.subscribe({ next: (submissionPatch: SubmissionPatch) => { - this.teamSubmissionWebsocketService.send(this.buildWebsocketTopic('/patch'), submissionPatch); + this.teamSubmissionWebsocketService.send(this.buildWebsocketTopic('/patch'), submissionPatch); }, error: (error) => this.onError(error), }); diff --git a/src/main/webapp/app/exercises/shared/team/team-participate/team-students-online-list.component.ts b/src/main/webapp/app/exercises/shared/team/team-participate/team-students-online-list.component.ts index c62d3c6538bd..1191ee1a4ecc 100644 --- a/src/main/webapp/app/exercises/shared/team/team-participate/team-students-online-list.component.ts +++ b/src/main/webapp/app/exercises/shared/team/team-participate/team-students-online-list.component.ts @@ -6,7 +6,7 @@ import { orderBy } from 'lodash-es'; import { Observable } from 'rxjs'; import { map, throttleTime } from 'rxjs/operators'; import dayjs from 'dayjs/esm'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; import { faCircle, faHistory } from '@fortawesome/free-solid-svg-icons'; import { NgClass } from '@angular/common'; @@ -23,7 +23,7 @@ import { captureException } from '@sentry/angular'; }) export class TeamStudentsOnlineListComponent implements OnInit, OnDestroy { private accountService = inject(AccountService); - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); readonly SHOW_TYPING_DURATION = 2000; // ms readonly SEND_TYPING_INTERVAL = this.SHOW_TYPING_DURATION / 1.5; @@ -68,14 +68,14 @@ export class TeamStudentsOnlineListComponent implements OnInit, OnDestroy { error: (error) => captureException(error), }); setTimeout(() => { - this.jhiWebsocketService.send(this.buildWebsocketTopic('/trigger'), {}); + this.jhiWebsocketService.send(this.buildWebsocketTopic('/trigger'), {}); }, 700); } private setupTypingIndicatorSender() { if (this.typing$) { this.typing$.pipe(throttleTime(this.SEND_TYPING_INTERVAL)).subscribe({ - next: () => this.jhiWebsocketService.send(this.buildWebsocketTopic('/typing'), {}), + next: () => this.jhiWebsocketService.send(this.buildWebsocketTopic('/typing'), {}), error: (error) => captureException(error), }); } diff --git a/src/main/webapp/app/exercises/shared/team/team.service.ts b/src/main/webapp/app/exercises/shared/team/team.service.ts index b7125de9aab1..deef69604bf4 100644 --- a/src/main/webapp/app/exercises/shared/team/team.service.ts +++ b/src/main/webapp/app/exercises/shared/team/team.service.ts @@ -2,7 +2,7 @@ import { HttpClient, HttpResponse } from '@angular/common/http'; import { Injectable, OnDestroy, inject } from '@angular/core'; import { AccountService } from 'app/core/auth/account.service'; import { User } from 'app/core/user/user.model'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { Course } from 'app/entities/course.model'; import { Exercise } from 'app/entities/exercise.model'; import { StudentWithTeam, Team, TeamAssignmentPayload, TeamImportStrategyType } from 'app/entities/team.model'; @@ -103,7 +103,7 @@ export interface ITeamService { @Injectable({ providedIn: 'root' }) export class TeamService implements ITeamService, OnDestroy { protected http = inject(HttpClient); - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private accountService = inject(AccountService); // Team Assignment Update Stream diff --git a/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.ts b/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.ts index 1e71e5bc940b..3ceecdb050a6 100644 --- a/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.ts +++ b/src/main/webapp/app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component.ts @@ -17,7 +17,6 @@ export class UnreferencedFeedbackComponent { unreferencedFeedback: Feedback[] = []; assessmentsAreValid: boolean; - feedbackDetailChanges = false; @Input() readOnly: boolean; @Input() highlightDifferences: boolean; @@ -45,6 +44,7 @@ export class UnreferencedFeedbackComponent { this.feedbacksChange.emit(this.unreferencedFeedback); this.validateFeedback(); } + /** * Validates the feedback: * - There must be any form of feedback, either general feedback or feedback referencing a model element or both @@ -69,7 +69,6 @@ export class UnreferencedFeedbackComponent { * @param feedback The feedback to update */ updateFeedback(feedback: Feedback) { - this.feedbackDetailChanges = true; const indexToUpdate = this.unreferencedFeedback.indexOf(feedback); if (indexToUpdate < 0) { this.unreferencedFeedback.push(feedback); @@ -135,16 +134,11 @@ export class UnreferencedFeedbackComponent { } createAssessmentOnDrop(event: Event) { - if (this.feedbackDetailChanges) { - this.feedbackDetailChanges = false; - return; - } this.addUnreferencedFeedback(); const newFeedback: Feedback | undefined = this.unreferencedFeedback.last(); if (newFeedback) { this.structuredGradingCriterionService.updateFeedbackWithStructuredGradingInstructionEvent(newFeedback, event); this.updateFeedback(newFeedback); - this.feedbackDetailChanges = false; } } } diff --git a/src/main/webapp/app/exercises/text/participate/text-editor.component.ts b/src/main/webapp/app/exercises/text/participate/text-editor.component.ts index 12c56eaa84b2..fc9698863136 100644 --- a/src/main/webapp/app/exercises/text/participate/text-editor.component.ts +++ b/src/main/webapp/app/exercises/text/participate/text-editor.component.ts @@ -397,6 +397,11 @@ export class TextEditorComponent implements OnInit, OnDestroy, ComponentCanDeact this.textSubmissionService.update(submissionToCreateOrUpdate, this.textExercise.id!).subscribe({ next: (response) => { this.submission = response.body!; + if (this.participation.team) { + // Make sure the team is not lost during update + const studentParticipation = this.submission.participation as StudentParticipation; + studentParticipation.team = this.participation.team; + } setLatestSubmissionResult(this.submission, getLatestSubmissionResult(this.submission)); this.submissionChange.next(this.submission); // reconnect so that the submission status is displayed correctly in the result.component @@ -447,7 +452,10 @@ export class TextEditorComponent implements OnInit, OnDestroy, ComponentCanDeact onReceiveSubmissionFromTeam(submission: TextSubmission) { submission.participation!.exercise = this.textExercise; submission.participation!.submissions = [submission]; - this.updateParticipation(submission.participation as StudentParticipation); + // Keep the existing team on the participation + const studentParticipation = submission.participation as StudentParticipation; + studentParticipation.team = this.participation.team; + this.updateParticipation(studentParticipation); } onTextEditorInput(event: Event) { diff --git a/src/main/webapp/app/iris/iris-status.service.ts b/src/main/webapp/app/iris/iris-status.service.ts index fc39d067860f..c0921699f32a 100644 --- a/src/main/webapp/app/iris/iris-status.service.ts +++ b/src/main/webapp/app/iris/iris-status.service.ts @@ -1,7 +1,7 @@ import { Injectable, OnDestroy, inject } from '@angular/core'; import { HttpClient, HttpResponse } from '@angular/common/http'; import { BehaviorSubject, Observable, Subscription, firstValueFrom } from 'rxjs'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { Response } from 'app/iris/iris-chat-http.service'; import { IrisStatusDTO } from 'app/entities/iris/iris-health.model'; import { IrisRateLimitInformation } from 'app/entities/iris/iris-ratelimit-info.model'; @@ -14,7 +14,7 @@ import { IrisRateLimitInformation } from 'app/entities/iris/iris-ratelimit-info. */ @Injectable({ providedIn: 'root' }) export class IrisStatusService implements OnDestroy { - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private httpClient = inject(HttpClient); intervalId: ReturnType | undefined; diff --git a/src/main/webapp/app/iris/iris-websocket.service.ts b/src/main/webapp/app/iris/iris-websocket.service.ts index 24c200d1650e..e6a828b6a0ef 100644 --- a/src/main/webapp/app/iris/iris-websocket.service.ts +++ b/src/main/webapp/app/iris/iris-websocket.service.ts @@ -1,5 +1,5 @@ import { Injectable, OnDestroy, inject } from '@angular/core'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { Observable, Subject, Subscription } from 'rxjs'; type SubscribedChannel = { wsSubscription: Subscription; subject: Subject }; @@ -9,7 +9,7 @@ type SubscribedChannel = { wsSubscription: Subscription; subject: Subject } */ @Injectable({ providedIn: 'root' }) export class IrisWebsocketService implements OnDestroy { - protected jhiWebsocketService = inject(JhiWebsocketService); + protected jhiWebsocketService = inject(WebsocketService); private subscribedChannels: Map = new Map(); diff --git a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts index 07defd4cac33..e60773e68607 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts +++ b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts @@ -6,7 +6,7 @@ import { faCircleCheck, faExclamationCircle, faExclamationTriangle, faPause, faP import dayjs from 'dayjs/esm'; import { TriggeredByPushTo } from 'app/entities/programming/repository-info.model'; import { ActivatedRoute } from '@angular/router'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { BuildQueueService } from 'app/localci/build-queue/build-queue.service'; import { AlertService, AlertType } from 'app/core/util/alert.service'; import { ArtemisSharedModule } from 'app/shared/shared.module'; @@ -21,7 +21,7 @@ import { SubmissionResultStatusModule } from 'app/overview/submission-result-sta imports: [ArtemisSharedModule, NgxDatatableModule, ArtemisDataTableModule, SubmissionResultStatusModule], }) export class BuildAgentDetailsComponent implements OnInit, OnDestroy { - private readonly websocketService = inject(JhiWebsocketService); + private readonly websocketService = inject(WebsocketService); private readonly buildAgentsService = inject(BuildAgentsService); private readonly route = inject(ActivatedRoute); private readonly buildQueueService = inject(BuildQueueService); diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts index 08d8772163bd..01d0b2b48389 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit, inject } from '@angular/core'; import { BuildAgentInformation, BuildAgentStatus } from 'app/entities/programming/build-agent-information.model'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; import { Subscription } from 'rxjs'; import { faPause, faPlay, faTimes } from '@fortawesome/free-solid-svg-icons'; @@ -20,7 +20,7 @@ import { NgxDatatableModule } from '@siemens/ngx-datatable'; imports: [ArtemisSharedModule, NgxDatatableModule, ArtemisDataTableModule], }) export class BuildAgentSummaryComponent implements OnInit, OnDestroy { - private readonly websocketService = inject(JhiWebsocketService); + private readonly websocketService = inject(WebsocketService); private readonly buildAgentsService = inject(BuildAgentsService); private readonly buildQueueService = inject(BuildQueueService); private readonly router = inject(Router); diff --git a/src/main/webapp/app/localci/build-queue/build-queue.component.ts b/src/main/webapp/app/localci/build-queue/build-queue.component.ts index 0f0a904c4375..159eda1eee97 100644 --- a/src/main/webapp/app/localci/build-queue/build-queue.component.ts +++ b/src/main/webapp/app/localci/build-queue/build-queue.component.ts @@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { BuildJob, BuildJobStatistics, FinishedBuildJob, SpanType } from 'app/entities/programming/build-job.model'; import { faAngleDown, faAngleRight, faCircleCheck, faExclamationCircle, faExclamationTriangle, faFilter, faSort, faSync, faTimes } from '@fortawesome/free-solid-svg-icons'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { BuildQueueService } from 'app/localci/build-queue/build-queue.service'; import { debounceTime, distinctUntilChanged, map, switchMap, take, tap } from 'rxjs/operators'; import { TriggeredByPushTo } from 'app/entities/programming/repository-info.model'; @@ -145,7 +145,7 @@ export enum FinishedBuildJobFilterStorageKey { }) export class BuildQueueComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private buildQueueService = inject(BuildQueueService); private alertService = inject(AlertService); private modalService = inject(NgbModal); diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts index 2a82da9afb1e..1e35a2cd7d20 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts @@ -260,7 +260,7 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { this.channelActions$ .pipe( debounceTime(500), - distinctUntilChanged((prev, curr) => prev.action === curr.action && prev.channel.id === curr.channel.id), + distinctUntilChanged((prev, curr) => prev.action === curr.action && prev.channel.id === curr.channel.id && prev.channel.name === curr.channel.name), takeUntil(this.ngUnsubscribe), ) .subscribe((channelAction) => { diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.html b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.html index 194d404fc58b..541123af9255 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.html +++ b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.html @@ -65,6 +65,31 @@ > + +
+
+ +
+ + + + +
+ +
+
diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.ts b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.ts index 975f1375e799..c2c567460793 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.ts +++ b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, OnChanges, OnDestroy, OnInit, Output, inject } from '@angular/core'; +import { Component, EventEmitter, OnChanges, OnDestroy, OnInit, Output, inject, output } from '@angular/core'; import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { ChannelIconComponent } from 'app/overview/course-conversations/other/channel-icon/channel-icon.component'; import { Subject, takeUntil } from 'rxjs'; @@ -10,6 +10,7 @@ export interface ChannelFormData { description?: string; isPublic?: boolean; isAnnouncementChannel?: boolean; + isCourseWideChannel?: boolean; } export type ChannelType = 'PUBLIC' | 'PRIVATE'; @@ -31,10 +32,12 @@ export class ChannelFormComponent implements OnInit, OnChanges, OnDestroy { description: undefined, isPublic: undefined, isAnnouncementChannel: undefined, + isCourseWideChannel: undefined, }; @Output() formSubmitted: EventEmitter = new EventEmitter(); @Output() channelTypeChanged: EventEmitter = new EventEmitter(); @Output() isAnnouncementChannelChanged: EventEmitter = new EventEmitter(); + isCourseWideChannelChanged = output(); form: FormGroup; @@ -54,6 +57,10 @@ export class ChannelFormComponent implements OnInit, OnChanges, OnDestroy { return this.form.get('isAnnouncementChannel'); } + get isisCourseWideChannelControl() { + return this.form.get('isCourseWideChannel'); + } + get isSubmitPossible() { return !this.form.invalid; } @@ -85,6 +92,7 @@ export class ChannelFormComponent implements OnInit, OnChanges, OnDestroy { description: [undefined, [Validators.maxLength(250)]], isPublic: [true, [Validators.required]], isAnnouncementChannel: [false, [Validators.required]], + isCourseWideChannel: [false, [Validators.required]], }); if (this.isPublicControl) { @@ -98,5 +106,11 @@ export class ChannelFormComponent implements OnInit, OnChanges, OnDestroy { this.isAnnouncementChannelChanged.emit(value); }); } + + if (this.isisCourseWideChannelControl) { + this.isisCourseWideChannelControl.valueChanges.pipe(takeUntil(this.ngUnsubscribe)).subscribe((value) => { + this.isCourseWideChannelChanged.emit(value); + }); + } } } diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html index e7c6413b8b22..87535ee616b0 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html +++ b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.html @@ -4,6 +4,7 @@
diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts index 14c66dfc94c5..b45f64ba0cd2 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts +++ b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts @@ -22,6 +22,7 @@ export class ChannelsCreateDialogComponent extends AbstractDialogComponent { channelToCreate: ChannelDTO = new ChannelDTO(); isPublicChannel = true; isAnnouncementChannel = false; + isCourseWideChannel = false; onChannelTypeChanged($event: ChannelType) { this.isPublicChannel = $event === 'PUBLIC'; @@ -31,16 +32,21 @@ export class ChannelsCreateDialogComponent extends AbstractDialogComponent { this.isAnnouncementChannel = $event; } + onIsCourseWideChannelChanged($event: boolean) { + this.isCourseWideChannel = $event; + } + onFormSubmitted($event: ChannelFormData) { this.createChannel($event); } createChannel(formData: ChannelFormData) { - const { name, description, isPublic, isAnnouncementChannel } = formData; + const { name, description, isPublic, isAnnouncementChannel, isCourseWideChannel } = formData; this.channelToCreate.name = name ? name.trim() : undefined; this.channelToCreate.description = description ? description.trim() : undefined; this.channelToCreate.isPublic = isPublic ?? false; this.channelToCreate.isAnnouncementChannel = isAnnouncementChannel ?? false; + this.channelToCreate.isCourseWide = isCourseWideChannel ?? false; this.close(this.channelToCreate); } } diff --git a/src/main/webapp/app/overview/course-overview.component.ts b/src/main/webapp/app/overview/course-overview.component.ts index 3056f4857a61..7e4554eff93a 100644 --- a/src/main/webapp/app/overview/course-overview.component.ts +++ b/src/main/webapp/app/overview/course-overview.component.ts @@ -41,7 +41,7 @@ import { } from '@fortawesome/free-solid-svg-icons'; import { NgbDropdown, NgbDropdownButtonItem, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { AlertService, AlertType } from 'app/core/util/alert.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; import { CourseStorageService } from 'app/course/manage/course-storage.service'; import { Course, isCommunicationEnabled, isMessagingEnabled } from 'app/entities/course.model'; @@ -132,7 +132,7 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit private courseStorageService = inject(CourseStorageService); private route = inject(ActivatedRoute); private teamService = inject(TeamService); - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private serverDateService = inject(ArtemisServerDateService); private alertService = inject(AlertService); private changeDetectorRef = inject(ChangeDetectorRef); diff --git a/src/main/webapp/app/overview/courses.component.ts b/src/main/webapp/app/overview/courses.component.ts index 156b295f450c..72e3fba935e7 100644 --- a/src/main/webapp/app/overview/courses.component.ts +++ b/src/main/webapp/app/overview/courses.component.ts @@ -6,7 +6,7 @@ import { HttpResponse } from '@angular/common/http'; import { GuidedTourService } from 'app/guided-tour/guided-tour.service'; import { courseOverviewTour } from 'app/guided-tour/tours/course-overview-tour'; import { TeamService } from 'app/exercises/shared/team/team.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import dayjs from 'dayjs/esm'; import { Exam } from 'app/entities/exam/exam.model'; import { Router, RouterLink } from '@angular/router'; @@ -43,7 +43,7 @@ export class CoursesComponent implements OnInit, OnDestroy { private courseService = inject(CourseManagementService); private guidedTourService = inject(GuidedTourService); private teamService = inject(TeamService); - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private router = inject(Router); private courseAccessStorageService = inject(CourseAccessStorageService); diff --git a/src/main/webapp/app/overview/participation-websocket.service.ts b/src/main/webapp/app/overview/participation-websocket.service.ts index 7e7f40e98f8d..7ec0ebeff526 100644 --- a/src/main/webapp/app/overview/participation-websocket.service.ts +++ b/src/main/webapp/app/overview/participation-websocket.service.ts @@ -6,7 +6,7 @@ import { Result } from 'app/entities/result.model'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; import { ParticipationService } from 'app/exercises/shared/participation/participation.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import dayjs from 'dayjs/esm'; import { cloneDeep } from 'lodash-es'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; @@ -25,7 +25,7 @@ export interface IParticipationWebsocketService { @Injectable({ providedIn: 'root' }) export class ParticipationWebsocketService implements IParticipationWebsocketService { - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private participationService = inject(ParticipationService); cachedParticipations: Map = new Map(); diff --git a/src/main/webapp/app/shared/components/course-exam-archive-button/course-exam-archive-button.component.ts b/src/main/webapp/app/shared/components/course-exam-archive-button/course-exam-archive-button.component.ts index 46eb02356885..216a4fd2e232 100644 --- a/src/main/webapp/app/shared/components/course-exam-archive-button/course-exam-archive-button.component.ts +++ b/src/main/webapp/app/shared/components/course-exam-archive-button/course-exam-archive-button.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; import { AlertService } from 'app/core/util/alert.service'; import { CourseManagementService } from 'app/course/manage/course-management.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { TranslateService } from '@ngx-translate/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { tap } from 'rxjs/operators'; @@ -37,7 +37,7 @@ export class CourseExamArchiveButtonComponent implements OnInit, OnDestroy { private courseService = inject(CourseManagementService); private examService = inject(ExamManagementService); private alertService = inject(AlertService); - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private translateService = inject(TranslateService); private modalService = inject(NgbModal); private accountService = inject(AccountService); diff --git a/src/main/webapp/app/shared/connection-status/connection-status.component.ts b/src/main/webapp/app/shared/connection-status/connection-status.component.ts index b29faab0f34a..34ce5ded38d5 100644 --- a/src/main/webapp/app/shared/connection-status/connection-status.component.ts +++ b/src/main/webapp/app/shared/connection-status/connection-status.component.ts @@ -1,7 +1,7 @@ import { Component, ContentChild, ElementRef, Input, OnDestroy, OnInit, inject } from '@angular/core'; import { faCircle, faExclamation, faTowerBroadcast } from '@fortawesome/free-solid-svg-icons'; import { Subscription } from 'rxjs'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { NgClass } from '@angular/common'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { TranslateDirective } from '../language/translate.directive'; @@ -13,7 +13,7 @@ import { TranslateDirective } from '../language/translate.directive'; imports: [NgClass, FaIconComponent, TranslateDirective], }) export class JhiConnectionStatusComponent implements OnInit, OnDestroy { - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); @ContentChild('innerContent', { static: false }) innerContent: ElementRef; @Input() isExamMode = false; diff --git a/src/main/webapp/app/shared/connection-warning/connection-warning.component.ts b/src/main/webapp/app/shared/connection-warning/connection-warning.component.ts index eae68db19f00..f9619d6c1986 100644 --- a/src/main/webapp/app/shared/connection-warning/connection-warning.component.ts +++ b/src/main/webapp/app/shared/connection-warning/connection-warning.component.ts @@ -1,7 +1,7 @@ import { Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; import { faExclamationCircle, faWifi } from '@fortawesome/free-solid-svg-icons'; import { Subscription, filter } from 'rxjs'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'; import { NavigationEnd, Router } from '@angular/router'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -16,7 +16,7 @@ import { NgClass } from '@angular/common'; imports: [FaIconComponent, TranslateDirective, CloseCircleComponent, NgClass, NgbPopover], }) export class JhiConnectionWarningComponent implements OnInit, OnDestroy { - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private router = inject(Router); @ViewChild('popover') popover: NgbPopover; diff --git a/src/main/webapp/app/shared/feature-toggle/feature-toggle.service.ts b/src/main/webapp/app/shared/feature-toggle/feature-toggle.service.ts index 283f60e2d313..244a5b7d5ccc 100644 --- a/src/main/webapp/app/shared/feature-toggle/feature-toggle.service.ts +++ b/src/main/webapp/app/shared/feature-toggle/feature-toggle.service.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { distinctUntilChanged, map, tap } from 'rxjs/operators'; import { HttpClient } from '@angular/common/http'; @@ -25,7 +25,7 @@ const defaultActiveFeatureState: ActiveFeatureToggles = Object.values(FeatureTog @Injectable({ providedIn: 'root' }) export class FeatureToggleService { - private websocketService = inject(JhiWebsocketService); + private websocketService = inject(WebsocketService); private http = inject(HttpClient); private readonly TOPIC = `/topic/management/feature-toggles`; diff --git a/src/main/webapp/app/shared/http/file.service.ts b/src/main/webapp/app/shared/http/file.service.ts index e4beba479b45..bd98edadb306 100644 --- a/src/main/webapp/app/shared/http/file.service.ts +++ b/src/main/webapp/app/shared/http/file.service.ts @@ -67,14 +67,26 @@ export class FileService { * @param downloadName the name given to the attachment */ downloadFileByAttachmentName(downloadUrl: string, downloadName: string) { + const normalizedDownloadUrl = this.createAttachmentFileUrl(downloadUrl, downloadName, true); + const newWindow = window.open('about:blank'); + newWindow!.location.href = normalizedDownloadUrl; + return newWindow; + } + + /** + * Creates the URL to download a attachment file + * + * @param downloadUrl url that is stored in the attachment model + * @param downloadName the name given to the attachment + * @param encodeName whether or not to encode the downloadName + */ + createAttachmentFileUrl(downloadUrl: string, downloadName: string, encodeName: boolean) { const downloadUrlComponents = downloadUrl.split('/'); // take the last element const extension = downloadUrlComponents.pop()!.split('.').pop(); const restOfUrl = downloadUrlComponents.join('/'); - const normalizedDownloadUrl = restOfUrl + '/' + encodeURIComponent(downloadName + '.' + extension); - const newWindow = window.open('about:blank'); - newWindow!.location.href = normalizedDownloadUrl; - return newWindow; + const encodedDownloadName = encodeName ? encodeURIComponent(downloadName + '.' + extension) : downloadName + '.' + extension; + return restOfUrl + '/' + encodedDownloadName; } /** diff --git a/src/main/webapp/app/shared/metis/metis-conversation.service.ts b/src/main/webapp/app/shared/metis/metis-conversation.service.ts index c0d4e0e0d50d..ffa6498da3df 100644 --- a/src/main/webapp/app/shared/metis/metis-conversation.service.ts +++ b/src/main/webapp/app/shared/metis/metis-conversation.service.ts @@ -2,7 +2,7 @@ import { Injectable, OnDestroy, inject } from '@angular/core'; import { EMPTY, Observable, ReplaySubject, Subject, Subscription, catchError, finalize, map, of, switchMap, tap } from 'rxjs'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { ConversationService } from 'app/shared/metis/conversations/conversation.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { AccountService } from 'app/core/auth/account.service'; import { User } from 'app/core/user/user.model'; import { ConversationWebsocketDTO } from 'app/entities/metis/conversation/conversation-websocket-dto.model'; @@ -30,7 +30,7 @@ export class MetisConversationService implements OnDestroy { private oneToOneChatService = inject(OneToOneChatService); private channelService = inject(ChannelService); protected conversationService = inject(ConversationService); - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private accountService = inject(AccountService); private alertService = inject(AlertService); private router = inject(Router); diff --git a/src/main/webapp/app/shared/metis/metis.service.ts b/src/main/webapp/app/shared/metis/metis.service.ts index 629c005d9509..441bda75f35e 100644 --- a/src/main/webapp/app/shared/metis/metis.service.ts +++ b/src/main/webapp/app/shared/metis/metis.service.ts @@ -23,7 +23,7 @@ import { } from 'app/shared/metis/metis.util'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { Params } from '@angular/router'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MetisPostDTO } from 'app/entities/metis/metis-post-dto.model'; import dayjs from 'dayjs/esm'; import { PlagiarismCase } from 'app/exercises/shared/plagiarism/types/PlagiarismCase'; @@ -41,7 +41,7 @@ export class MetisService implements OnDestroy { protected reactionService = inject(ReactionService); protected accountService = inject(AccountService); protected exerciseService = inject(ExerciseService); - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private conversationService = inject(ConversationService); private posts$: ReplaySubject = new ReplaySubject(1); diff --git a/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts b/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts index 826adfaf7f36..a0c4097c52a4 100644 --- a/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts +++ b/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts @@ -46,6 +46,7 @@ import { OrderedListAction } from 'app/shared/monaco-editor/model/actions/ordere import { StrikethroughAction } from 'app/shared/monaco-editor/model/actions/strikethrough.action'; import { PostingContentComponent } from '../posting-content/posting-content.components'; import { NgStyle } from '@angular/common'; +import { FileService } from 'app/shared/http/file.service'; @Component({ selector: 'jhi-posting-markdown-editor', @@ -64,6 +65,7 @@ import { NgStyle } from '@angular/common'; export class PostingMarkdownEditorComponent implements OnInit, ControlValueAccessor, AfterContentChecked, AfterViewInit { private cdref = inject(ChangeDetectorRef); private metisService = inject(MetisService); + private fileService = inject(FileService); private courseManagementService = inject(CourseManagementService); private lectureService = inject(LectureService); private channelService = inject(ChannelService); @@ -119,7 +121,7 @@ export class PostingMarkdownEditorComponent implements OnInit, ControlValueAcces ...faqAction, ]; - this.lectureAttachmentReferenceAction = new LectureAttachmentReferenceAction(this.metisService, this.lectureService); + this.lectureAttachmentReferenceAction = new LectureAttachmentReferenceAction(this.metisService, this.lectureService, this.fileService); } ngAfterViewInit(): void { diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/lecture-attachment-reference.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/lecture-attachment-reference.action.ts index 09750e9c7b03..cd42d3318a65 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/lecture-attachment-reference.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/lecture-attachment-reference.action.ts @@ -9,6 +9,8 @@ import { Slide } from 'app/entities/lecture-unit/slide.model'; import { LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; import { TextEditor } from 'app/shared/monaco-editor/model/actions/adapter/text-editor.interface'; import { sanitizeStringForMarkdownEditor } from 'app/shared/util/markdown.util'; +import { FileService } from 'app/shared/http/file.service'; +import { cloneDeep } from 'lodash-es'; interface LectureWithDetails { id: number; @@ -37,6 +39,7 @@ export class LectureAttachmentReferenceAction extends TextEditorAction { constructor( private readonly metisService: MetisService, private readonly lectureService: LectureService, + private readonly fileService: FileService, ) { super(LectureAttachmentReferenceAction.ID, 'artemisApp.metis.editor.lecture'); firstValueFrom(this.lectureService.findAllByCourseIdWithSlides(this.metisService.getCourse().id!)).then((response) => { @@ -44,12 +47,22 @@ export class LectureAttachmentReferenceAction extends TextEditorAction { if (lectures) { this.lecturesWithDetails = lectures .filter((lecture) => !!lecture.id && !!lecture.title) - .map((lecture) => ({ - id: lecture.id!, - title: lecture.title!, - attachmentUnits: lecture.lectureUnits?.filter((unit) => unit.type === LectureUnitType.ATTACHMENT), - attachments: lecture.attachments, - })); + .map((lecture) => { + const attachmentsWithFileUrls = cloneDeep(lecture.attachments)?.map((attachment) => { + if (attachment.link && attachment.name) { + attachment.link = this.fileService.createAttachmentFileUrl(attachment.link!, attachment.name!, false); + } + + return attachment; + }); + + return { + id: lecture.id!, + title: lecture.title!, + attachmentUnits: lecture.lectureUnits?.filter((unit) => unit.type === LectureUnitType.ATTACHMENT), + attachments: attachmentsWithFileUrls, + }; + }); } }); } diff --git a/src/main/webapp/app/shared/notification/notification.service.ts b/src/main/webapp/app/shared/notification/notification.service.ts index fb009fdab982..fc35afe7af6e 100644 --- a/src/main/webapp/app/shared/notification/notification.service.ts +++ b/src/main/webapp/app/shared/notification/notification.service.ts @@ -6,7 +6,7 @@ import { filter, map } from 'rxjs/operators'; import { createRequestOption } from 'app/shared/util/request.util'; import { ActivatedRoute, NavigationEnd, Params, Router } from '@angular/router'; import { AccountService } from 'app/core/auth/account.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { User } from 'app/core/user/user.model'; import { GroupNotification, GroupNotificationType } from 'app/entities/group-notification.model'; import { @@ -65,7 +65,7 @@ const MESSAGING_NOTIFICATION_TEXTS = [ @Injectable({ providedIn: 'root' }) export class NotificationService { - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private router = inject(Router); private http = inject(HttpClient); private accountService = inject(AccountService); diff --git a/src/main/webapp/app/shared/notification/system-notification/system-notification.component.ts b/src/main/webapp/app/shared/notification/system-notification/system-notification.component.ts index a6e05379c1d9..bbec4ac631fa 100644 --- a/src/main/webapp/app/shared/notification/system-notification/system-notification.component.ts +++ b/src/main/webapp/app/shared/notification/system-notification/system-notification.component.ts @@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit, inject } from '@angular/core'; import dayjs from 'dayjs/esm'; import { SystemNotification, SystemNotificationType } from 'app/entities/system-notification.model'; import { AccountService } from 'app/core/auth/account.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { User } from 'app/core/user/user.model'; import { SystemNotificationService } from 'app/shared/notification/system-notification/system-notification.service'; import { faExclamationTriangle, faInfoCircle, faTimes } from '@fortawesome/free-solid-svg-icons'; @@ -21,7 +21,7 @@ export const WEBSOCKET_CHANNEL = '/topic/system-notification'; }) export class SystemNotificationComponent implements OnInit, OnDestroy { private accountService = inject(AccountService); - private jhiWebsocketService = inject(JhiWebsocketService); + private jhiWebsocketService = inject(WebsocketService); private systemNotificationService = inject(SystemNotificationService); readonly INFO = SystemNotificationType.INFO; diff --git a/src/main/webapp/i18n/de/conversation.json b/src/main/webapp/i18n/de/conversation.json index 1e35654026e4..d3319ce67e1d 100644 --- a/src/main/webapp/i18n/de/conversation.json +++ b/src/main/webapp/i18n/de/conversation.json @@ -208,6 +208,7 @@ "createChannel": { "titlePublicChannel": "Erstelle einen öffentlichen", "titlePrivateChannel": "Erstelle einen privaten", + "titleCourseWideChannel": "kursweit", "titleAnnouncementChannel": "Ankündigungskanal", "titleRegularChannel": "Kanal", "description": "Ein Kanal ist eine Möglichkeit, Menschen für ein Projekt, ein Thema oder nur zum Spaß zusammenzubringen. Du kannst so viele Kanäle erstellen, wie du möchtest. Du wirst der / die erste Kanalmoderator:in werden. Du wirst den Kanal nicht verlassen können.", @@ -236,6 +237,12 @@ "true": "Ankündigungskanal", "false": "Uneingeschränkter Kanal" }, + "isCourseWideChannelInput": { + "label": "Kanalbereich", + "explanation": "In einem kursweiten Kanal werden alle Benutzer des Kurses automatisch hinzugefügt. In einem ausgewählten Kanal kannst du die hinzuzufügenden Benutzer manuell auswählen.", + "true": "Kursweiter Kanal", + "false": "Ausgewählter Kanal" + }, "createButton": "Kanal erstellen" } } diff --git a/src/main/webapp/i18n/en/conversation.json b/src/main/webapp/i18n/en/conversation.json index 93c350bf4c55..8a56f4ad8025 100644 --- a/src/main/webapp/i18n/en/conversation.json +++ b/src/main/webapp/i18n/en/conversation.json @@ -208,6 +208,7 @@ "createChannel": { "titlePublicChannel": "Create a public", "titlePrivateChannel": "Create a private", + "titleCourseWideChannel": "course-wide", "titleAnnouncementChannel": "announcement channel", "titleRegularChannel": "channel", "description": "A channel is a way to group people together around a project, a topic, or just for fun. You can create as many channels as you want. You will become the first channel moderator. You will not be able to leave the channel.", @@ -236,6 +237,12 @@ "true": "Announcement Channel", "false": "Unrestricted Channel" }, + "isCourseWideChannelInput": { + "label": "Channel Scope", + "explanation": "In a course-wide channel, all users in the course are automatically added. In a selective channel, you can manually select the users to be added after creation.", + "true": "Course-wide Channel", + "false": "Selective Channel" + }, "createButton": "Create Channel" } } diff --git a/src/test/java/de/tum/cit/aet/artemis/assessment/architecture/AssessmentCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/assessment/architecture/AssessmentCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..4fc43324c1af --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/assessment/architecture/AssessmentCodeStyleArchitectureTest.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.assessment.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class AssessmentCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".assessment"; + } + + @Override + protected int dtoAsAnnotatedRecordThreshold() { + return 3; + } + + @Override + protected int dtoNameEndingThreshold() { + return 1; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/athena/architecture/AthenaCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/athena/architecture/AthenaCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..7c3d9804dc2b --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/athena/architecture/AthenaCodeStyleArchitectureTest.java @@ -0,0 +1,16 @@ +package de.tum.cit.aet.artemis.athena.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class AthenaCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".athena"; + } + + @Override + protected int dtoNameEndingThreshold() { + return 1; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/architecture/AtlasCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/architecture/AtlasCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..f6289d2cd019 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/architecture/AtlasCodeStyleArchitectureTest.java @@ -0,0 +1,16 @@ +package de.tum.cit.aet.artemis.atlas.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class AtlasCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".atlas"; + } + + @Override + protected int dtoNameEndingThreshold() { + return 4; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/AbstractConversationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/AbstractConversationTest.java index ee5556a87372..0c1952c01941 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/AbstractConversationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/AbstractConversationTest.java @@ -180,6 +180,7 @@ ChannelDTO createChannel(boolean isPublicChannel, String name) throws Exception channelDTO.setIsPublic(isPublicChannel); channelDTO.setIsAnnouncementChannel(false); channelDTO.setDescription("general channel"); + channelDTO.setIsCourseWide(false); var chat = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/channels", channelDTO, ChannelDTO.class, HttpStatus.CREATED); resetWebsocketMock(); diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java index 067b1c4626df..5b6bbff36d65 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java @@ -9,6 +9,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import org.junit.jupiter.api.AfterEach; @@ -22,6 +23,7 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.util.LinkedMultiValueMap; +import de.tum.cit.aet.artemis.communication.domain.ConversationParticipant; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.dto.ChannelDTO; import de.tum.cit.aet.artemis.communication.dto.ChannelIdAndNameDTO; @@ -136,6 +138,7 @@ private void isAllowedToCreateChannelTest(boolean isPublicChannel, String loginN channelDTO.setIsPublic(isPublicChannel); channelDTO.setIsAnnouncementChannel(false); channelDTO.setDescription("general channel"); + channelDTO.setIsCourseWide(false); // when var chat = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/channels", channelDTO, ChannelDTO.class, HttpStatus.CREATED); @@ -170,6 +173,7 @@ void createTest_messagingDeactivated(CourseInformationSharingConfiguration cours channelDTO.setIsAnnouncementChannel(false); channelDTO.setName(TEST_PREFIX); channelDTO.setDescription("general channel"); + channelDTO.setIsCourseWide(false); expectCreateForbidden(channelDTO); @@ -188,6 +192,7 @@ void update_messagingFeatureDeactivated_shouldReturnForbidden() throws Exception channelDTO.setIsAnnouncementChannel(false); channelDTO.setName(TEST_PREFIX); channelDTO.setDescription("general channel"); + channelDTO.setIsCourseWide(false); expectUpdateForbidden(1L, channelDTO); @@ -233,6 +238,7 @@ void createChannel_asNonCourseInstructorOrTutorOrEditor_shouldReturnForbidden(bo channelDTO.setIsPublic(isPublicChannel); channelDTO.setIsAnnouncementChannel(false); channelDTO.setDescription("general channel"); + channelDTO.setIsCourseWide(false); // then expectCreateForbidden(channelDTO); @@ -931,6 +937,7 @@ void createFeedbackChannel_asStudent_shouldReturnForbidden() { channelDTO.setDescription("Discussion channel for feedback"); channelDTO.setIsPublic(true); channelDTO.setIsAnnouncementChannel(false); + channelDTO.setIsCourseWide(false); FeedbackChannelRequestDTO feedbackChannelRequest = new FeedbackChannelRequestDTO(channelDTO, List.of("Sample feedback text"), "Sample testName"); @@ -955,6 +962,7 @@ void createFeedbackChannel_asInstructor_shouldCreateChannel() { channelDTO.setDescription("Discussion channel for feedback"); channelDTO.setIsPublic(true); channelDTO.setIsAnnouncementChannel(false); + channelDTO.setIsCourseWide(false); FeedbackChannelRequestDTO feedbackChannelRequest = new FeedbackChannelRequestDTO(channelDTO, List.of("Sample feedback text"), "Sample testName"); @@ -976,7 +984,7 @@ void createFeedbackChannel_asInstructor_shouldCreateChannel() { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void markAllChannelsAsRead() throws Exception { + void shouldMarkAllChannelsAsReadWhenCallingResource() throws Exception { // ensure there exist at least two channel with unread messages in the course createChannel(true, "channel1"); createChannel(true, "channel2"); @@ -993,8 +1001,10 @@ void markAllChannelsAsRead() throws Exception { request.postWithoutLocation("/api/courses/" + exampleCourseId + "/channels/mark-as-read", null, HttpStatus.OK, null); List updatedChannels = channelRepository.findChannelsByCourseId(exampleCourseId); updatedChannels.forEach(channel -> { - var conversationParticipant = conversationParticipantRepository.findConversationParticipantByConversationIdAndUserId(channel.getId(), instructor1.getId()); - await().untilAsserted(() -> assertThat(conversationParticipant.get().getUnreadMessagesCount()).isZero()); // async db call, so we need to wait + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + var participant = conversationParticipantRepository.findConversationParticipantByConversationIdAndUserId(channel.getId(), instructor1.getId()); + assertThat(participant).isPresent().get().extracting(ConversationParticipant::getUnreadMessagesCount).isEqualTo(0L); + }); }); } diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/GroupChatIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/GroupChatIntegrationTest.java index 8f9e023fabf7..ab1cd2f42cba 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/GroupChatIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/GroupChatIntegrationTest.java @@ -323,6 +323,24 @@ void deregisterUsersFromGroupChat_asNonMember_shouldReturnForbidden() throws Exc conversationRepository.delete(conversation); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void shouldReturnExistingGroupChatWhenChatAlreadyExists() throws Exception { + var chat1 = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/group-chats", List.of(testPrefix + "student2", testPrefix + "student3"), GroupChatDTO.class, + HttpStatus.CREATED); + + var chat2 = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/group-chats", List.of(testPrefix + "student2", testPrefix + "student3"), GroupChatDTO.class, + HttpStatus.CREATED); + + assertThat(chat1).isNotNull(); + assertThat(chat2).isNotNull(); + assertThat(chat1.getId()).isEqualTo(chat2.getId()); + assertParticipants(chat1.getId(), 3, "student1", "student2", "student3"); + + var conversation = groupChatRepository.findById(chat1.getId()).orElseThrow(); + conversationRepository.delete(conversation); + } + private GroupChatDTO createGroupChatWithStudent1To3() throws Exception { return this.createGroupChat("student2", "student3"); } diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/MessageIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/MessageIntegrationTest.java index d9b8f3be40e1..8b9bc1023590 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/MessageIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/MessageIntegrationTest.java @@ -696,8 +696,7 @@ void testDecreaseUnreadMessageCountWhenDeletingMessage() throws Exception { final var student1 = userTestRepository.findOneByLogin(TEST_PREFIX + "student1").orElseThrow(); final var student2 = userTestRepository.findOneByLogin(TEST_PREFIX + "student2").orElseThrow(); - Post postToSave1 = createPostWithOneToOneChat(TEST_PREFIX); // OneToOneChat 1 - Post postToSave2 = createPostWithOneToOneChat(TEST_PREFIX); // OneToOneChat 1 + Post postToSave1 = createPostWithOneToOneChat(TEST_PREFIX); Post createdPost1 = request.postWithResponseBody("/api/courses/" + courseId + "/messages", postToSave1, Post.class, HttpStatus.CREATED); final var oneToOneChat1 = createdPost1.getConversation(); @@ -708,24 +707,11 @@ void testDecreaseUnreadMessageCountWhenDeletingMessage() throws Exception { assertThat(getUnreadMessagesCount(oneToOneChat1, student2)).isEqualTo(1); }); - Post createdPost2 = request.postWithResponseBody("/api/courses/" + courseId + "/messages", postToSave2, Post.class, HttpStatus.CREATED); - final var oneToOneChat2 = createdPost2.getConversation(); - // student 1 adds a message, so the unread count for student 2 should be 1 + request.delete("/api/courses/" + courseId + "/messages/" + createdPost1.getId(), HttpStatus.OK); + // After deleting the message in the chat, the unread count in the chat should become 0 await().untilAsserted(() -> { SecurityUtils.setAuthorizationObject(); - assertThat(getUnreadMessagesCount(oneToOneChat1, student1)).isZero(); - assertThat(getUnreadMessagesCount(oneToOneChat1, student2)).isEqualTo(1); - }); - - request.delete("/api/courses/" + courseId + "/messages/" + createdPost2.getId(), HttpStatus.OK); - // After deleting the message in the second chat, the unread count in the first chat should stay the same, the unread count in the second chat will become 0 - await().untilAsserted(() -> { - SecurityUtils.setAuthorizationObject(); - assertThat(getUnreadMessagesCount(oneToOneChat1, student2)).isEqualTo(1); - assertThat(getUnreadMessagesCount(oneToOneChat2, student2)).isZero(); - - // no changes for student1 - assertThat(getUnreadMessagesCount(oneToOneChat1, student1)).isZero(); + assertThat(getUnreadMessagesCount(oneToOneChat1, student2)).isZero(); assertThat(getUnreadMessagesCount(oneToOneChat1, student1)).isZero(); }); } diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/architecture/CommunicationCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/architecture/CommunicationCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..5d0d6e3dc705 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/communication/architecture/CommunicationCodeStyleArchitectureTest.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.communication.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class CommunicationCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".communication"; + } + + @Override + protected int dtoAsAnnotatedRecordThreshold() { + return 7; + } + + @Override + protected int dtoNameEndingThreshold() { + return 6; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/core/architecture/CoreCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/core/architecture/CoreCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..3106bcd0e0b0 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/core/architecture/CoreCodeStyleArchitectureTest.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.core.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class CoreCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".core"; + } + + @Override + protected int dtoAsAnnotatedRecordThreshold() { + return 10; + } + + @Override + protected int dtoNameEndingThreshold() { + return 10; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationIntegrationTestHelper.java b/src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationIntegrationTestHelper.java index bc10fb0d9dce..c757a43b8fa8 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationIntegrationTestHelper.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationIntegrationTestHelper.java @@ -4,6 +4,9 @@ import jakarta.servlet.http.Cookie; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; +import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider; + public class AuthenticationIntegrationTestHelper { public static void authenticationCookieAssertions(Cookie cookie, boolean logoutCookie) { @@ -21,4 +24,19 @@ public static void authenticationCookieAssertions(Cookie cookie, boolean logoutC assertThat(cookie.getValue()).isNotEmpty(); } } + + public static void toolTokenAssertions(TokenProvider tokenProvider, String token, long initialLifetime, ToolTokenType... tools) { + assertThat(token).isNotNull(); + + String[] toolClaims = tokenProvider.getClaim(token, "tools", String.class).split(","); + assertThat(toolClaims).isNotEmpty(); + for (ToolTokenType tool : tools) { + assertThat(toolClaims).contains(tool.toString()); + } + + var lifetime = tokenProvider.getExpirationDate(token).getTime() - System.currentTimeMillis(); + // assert that the token has a lifetime of less than a day + assertThat(lifetime).isLessThan(24 * 60 * 60 * 1000); + assertThat(lifetime).isLessThan(initialLifetime); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/core/authentication/InternalAuthenticationIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/core/authentication/InternalAuthenticationIntegrationTest.java index 69b14ab0ba8d..231a0e2bbd55 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/authentication/InternalAuthenticationIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/authentication/InternalAuthenticationIntegrationTest.java @@ -6,6 +6,8 @@ import static de.tum.cit.aet.artemis.core.domain.Authority.USER_AUTHORITY; import static de.tum.cit.aet.artemis.core.user.util.UserFactory.USER_PASSWORD; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.ZonedDateTime; import java.util.HashSet; @@ -14,6 +16,7 @@ import java.util.Optional; import java.util.Set; +import jakarta.servlet.http.Cookie; import jakarta.validation.constraints.NotNull; import org.junit.jupiter.api.AfterEach; @@ -23,9 +26,11 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.test.context.support.WithAnonymousUser; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.util.LinkedMultiValueMap; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -39,6 +44,8 @@ import de.tum.cit.aet.artemis.core.repository.AuthorityRepository; import de.tum.cit.aet.artemis.core.security.Role; import de.tum.cit.aet.artemis.core.security.SecurityUtils; +import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType; +import de.tum.cit.aet.artemis.core.security.jwt.JWTCookieService; import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider; import de.tum.cit.aet.artemis.core.service.user.PasswordService; import de.tum.cit.aet.artemis.core.util.CourseFactory; @@ -58,6 +65,9 @@ class InternalAuthenticationIntegrationTest extends AbstractSpringIntegrationJen @Autowired private TokenProvider tokenProvider; + @Autowired + private JWTCookieService jwtCookieService; + @Autowired private ProgrammingExerciseTestRepository programmingExerciseRepository; @@ -237,6 +247,25 @@ void testJWTAuthentication() throws Exception { assertThat(tokenProvider.validateTokenForAuthority(responseBody.get("access_token").toString(), null)).isTrue(); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testScorpioTokenGeneration() throws Exception { + ResponseCookie responseCookie = jwtCookieService.buildLoginCookie(true); + + Cookie cookie = new Cookie(responseCookie.getName(), responseCookie.getValue()); + cookie.setMaxAge((int) responseCookie.getMaxAge().toMillis()); + + var initialLifetime = tokenProvider.getExpirationDate(cookie.getValue()).getTime() - System.currentTimeMillis(); + + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + params.add("tool", ToolTokenType.SCORPIO.toString()); + + var responseBody = request.performMvcRequest(post("/api/tool-token").cookie(cookie).params(params)).andExpect(status().isOk()).andReturn().getResponse() + .getContentAsString(); + + AuthenticationIntegrationTestHelper.toolTokenAssertions(tokenProvider, responseBody, initialLifetime, ToolTokenType.SCORPIO); + } + @Test @WithAnonymousUser void testJWTAuthenticationLogoutAnonymous() throws Exception { diff --git a/src/test/java/de/tum/cit/aet/artemis/core/config/websocket/GzipMessageConverterTest.java b/src/test/java/de/tum/cit/aet/artemis/core/config/websocket/GzipMessageConverterTest.java new file mode 100644 index 000000000000..2b610f39bf67 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/core/config/websocket/GzipMessageConverterTest.java @@ -0,0 +1,127 @@ +package de.tum.cit.aet.artemis.core.config.websocket; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPOutputStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.NativeMessageHeaderAccessor; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.tum.cit.aet.artemis.communication.dto.AuthorDTO; + +class GzipMessageConverterTest { + + private GzipMessageConverter converter; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + converter = new GzipMessageConverter(objectMapper); + } + + @Test + void testSupportsAnyClass() { + assertThat(converter.supports(String.class)).isTrue(); + assertThat(converter.supports(Object.class)).isTrue(); + } + + @Test + void testConvertFromInternalWithCompressedPayload() throws Exception { + // Arrange + var author = new AuthorDTO(1L, "Test", "Test"); + String payload = objectMapper.writeValueAsString(author); + byte[] compressedPayload = compressAndEncode(payload.getBytes()).getBytes(); + + Message message = mock(Message.class); + MessageHeaders headers = mock(MessageHeaders.class); + Map nativeHeaders = Map.of(GzipMessageConverter.COMPRESSION_HEADER_KEY, List.of("true")); + + when(message.getPayload()).thenReturn(compressedPayload); + when(message.getHeaders()).thenReturn(headers); + when(headers.get(NativeMessageHeaderAccessor.NATIVE_HEADERS)).thenReturn(nativeHeaders); + + // Act + Object result = converter.convertFromInternal(message, AuthorDTO.class, null); + + // Assert + assertThat(result).isNotNull(); + assertThat(result).isEqualTo(author); + } + + @Test + void testConvertFromInternalWithoutCompressedPayload() throws Exception { + // Arrange + var author = new AuthorDTO(1L, "Test", "Test"); + String payload = objectMapper.writeValueAsString(author); + Message message = mock(Message.class); + MessageHeaders headers = mock(MessageHeaders.class); + + when(message.getPayload()).thenReturn(payload); + when(message.getHeaders()).thenReturn(headers); + when(headers.get(NativeMessageHeaderAccessor.NATIVE_HEADERS)).thenReturn(null); + + // Act + Object result = converter.convertFromInternal(message, AuthorDTO.class, null); + + // Assert + assertThat(result).isEqualTo(author); + } + + @Test + void testConvertToInternalWithCompressionEnabled() throws Exception { + // Arrange + var author = new AuthorDTO(1L, "Test", "Test"); + String payload = objectMapper.writeValueAsString(author); + byte[] payloadBytes = payload.getBytes(); + + MessageHeaders headers = mock(MessageHeaders.class); + Map nativeHeaders = Map.of(GzipMessageConverter.COMPRESSION_HEADER_KEY, List.of("true")); + + when(headers.get(NativeMessageHeaderAccessor.NATIVE_HEADERS)).thenReturn(nativeHeaders); + + // Act + Object result = converter.convertToInternal(payloadBytes, headers, null); + + // Assert + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(byte[].class); + } + + @Test + void testConvertToInternalWithoutCompression() throws Exception { + // Arrange + var author = new AuthorDTO(1L, "Test", "Test"); + String payload = objectMapper.writeValueAsString(author); + byte[] payloadBytes = payload.getBytes(); + + MessageHeaders headers = mock(MessageHeaders.class); + when(headers.get(NativeMessageHeaderAccessor.NATIVE_HEADERS)).thenReturn(null); + + // Act + Object result = converter.convertToInternal(author, headers, null); + + // Assert + assertThat(result).isEqualTo(payloadBytes); + } + + // Utility for compression + private String compressAndEncode(byte[] data) throws Exception { + try (var byteStream = new ByteArrayOutputStream(); var gzipStream = new GZIPOutputStream(byteStream)) { + gzipStream.write(data); + gzipStream.finish(); + return Base64.getEncoder().encodeToString(byteStream.toByteArray()); + } + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/core/security/allowedTools/AllowedToolsResource.java b/src/test/java/de/tum/cit/aet/artemis/core/security/allowedTools/AllowedToolsResource.java new file mode 100644 index 000000000000..2f8e63ed11a6 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/core/security/allowedTools/AllowedToolsResource.java @@ -0,0 +1,30 @@ +package de.tum.cit.aet.artemis.core.security.allowedTools; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; + +@Profile(PROFILE_CORE) +@RestController +@RequestMapping("api/test/") +public class AllowedToolsResource { + + @GetMapping("testAllowedToolTokenScorpio") + @EnforceAtLeastStudent + @AllowedTools(ToolTokenType.SCORPIO) + public ResponseEntity testAllowedToolTokenScorpio() { + return ResponseEntity.ok().build(); + } + + @GetMapping("testNoAllowedToolToken") + @EnforceAtLeastStudent + public ResponseEntity testNoAllowedToolToken() { + return ResponseEntity.ok().build(); + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/core/security/allowedTools/AllowedToolsTest.java b/src/test/java/de/tum/cit/aet/artemis/core/security/allowedTools/AllowedToolsTest.java new file mode 100644 index 000000000000..c9a988cf336a --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/core/security/allowedTools/AllowedToolsTest.java @@ -0,0 +1,69 @@ +package de.tum.cit.aet.artemis.core.security.allowedTools; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; + +import jakarta.servlet.http.Cookie; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.context.support.WithMockUser; + +import de.tum.cit.aet.artemis.core.security.Role; +import de.tum.cit.aet.artemis.core.security.jwt.JWTFilter; +import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider; +import de.tum.cit.aet.artemis.shared.base.AbstractSpringIntegrationIndependentTest; + +class AllowedToolsTest extends AbstractSpringIntegrationIndependentTest { + + private static final String TEST_PREFIX = "allowedtools"; + + @Autowired + private TokenProvider tokenProvider; + + @BeforeEach + void initTestCase() { + userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0); + } + + @Test + void testAllowedToolsRouteWithToolToken() throws Exception { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken("test-user", "test-password", + Collections.singletonList(new SimpleGrantedAuthority(Role.STUDENT.getAuthority()))); + + String jwt = tokenProvider.createToken(authentication, 24 * 60 * 60 * 1000, ToolTokenType.SCORPIO); + Cookie cookie = new Cookie(JWTFilter.JWT_COOKIE_NAME, jwt); + + request.performMvcRequest(get("/api/test/testAllowedToolTokenScorpio").cookie(cookie)).andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testAllowedToolsRouteWithGeneralToken() throws Exception { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken("test-user", "test-password", + Collections.singletonList(new SimpleGrantedAuthority(Role.STUDENT.getAuthority()))); + + String jwt = tokenProvider.createToken(authentication, 24 * 60 * 60 * 1000, null); + Cookie cookie = new Cookie(JWTFilter.JWT_COOKIE_NAME, jwt); + + request.performMvcRequest(get("/api/test/testAllowedToolTokenScorpio").cookie(cookie)).andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testAllowedToolsRouteWithDifferentToolToken() throws Exception { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken("test-user", "test-password", + Collections.singletonList(new SimpleGrantedAuthority(Role.STUDENT.getAuthority()))); + + String jwt = tokenProvider.createToken(authentication, 24 * 60 * 60 * 1000, ToolTokenType.SCORPIO); + Cookie cookie = new Cookie(JWTFilter.JWT_COOKIE_NAME, jwt); + + request.performMvcRequest(get("/api/test/testNoAllowedToolToken").cookie(cookie)).andExpect(status().isForbidden()); + } + +} diff --git a/src/test/java/de/tum/cit/aet/artemis/exam/architecture/ExamCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/exam/architecture/ExamCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..e108a581f7e4 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/exam/architecture/ExamCodeStyleArchitectureTest.java @@ -0,0 +1,16 @@ +package de.tum.cit.aet.artemis.exam.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class ExamCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".exam"; + } + + @Override + protected int dtoNameEndingThreshold() { + return 4; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/exercise/architecture/ExerciseCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/exercise/architecture/ExerciseCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..10d68925cfdd --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/exercise/architecture/ExerciseCodeStyleArchitectureTest.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.exercise.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class ExerciseCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".exercise"; + } + + @Override + protected int dtoAsAnnotatedRecordThreshold() { + return 2; + } + + @Override + protected int dtoNameEndingThreshold() { + return 7; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/fileupload/architecture/FileUploadCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/fileupload/architecture/FileUploadCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..a4df22af3dca --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/fileupload/architecture/FileUploadCodeStyleArchitectureTest.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.fileupload.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class FileUploadCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".fileupload"; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/architecture/IrisCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/architecture/IrisCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..28c42c67d205 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/iris/architecture/IrisCodeStyleArchitectureTest.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.iris.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class IrisCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".iris"; + } + + @Override + protected int dtoAsAnnotatedRecordThreshold() { + return 2; + } + + @Override + protected int dtoNameEndingThreshold() { + return 4; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/lecture/architecture/LectureCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/lecture/architecture/LectureCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..f7ab2161169f --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/lecture/architecture/LectureCodeStyleArchitectureTest.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.lecture.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class LectureCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".lecture"; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/lti/architecture/LtiCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/lti/architecture/LtiCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..911a388cab3b --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/lti/architecture/LtiCodeStyleArchitectureTest.java @@ -0,0 +1,16 @@ +package de.tum.cit.aet.artemis.lti.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class LtiCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".lti"; + } + + @Override + protected int dtoNameEndingThreshold() { + return 11; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/modeling/architecture/ModelingCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/modeling/architecture/ModelingCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..d6dc2219ab45 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/modeling/architecture/ModelingCodeStyleArchitectureTest.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.modeling.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class ModelingCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".modeling"; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/plagiarism/architecture/PlagiarismCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/plagiarism/architecture/PlagiarismCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..7acab6337b89 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/plagiarism/architecture/PlagiarismCodeStyleArchitectureTest.java @@ -0,0 +1,16 @@ +package de.tum.cit.aet.artemis.plagiarism.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class PlagiarismCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".plagiarism"; + } + + @Override + protected int dtoNameEndingThreshold() { + return 1; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/architecture/ProgrammingCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/architecture/ProgrammingCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..35687c7a9667 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/programming/architecture/ProgrammingCodeStyleArchitectureTest.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.programming.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class ProgrammingCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".programming"; + } + + @Override + protected int dtoAsAnnotatedRecordThreshold() { + return 1; + } + + @Override + protected int dtoNameEndingThreshold() { + return 17; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/quiz/architecture/QuizCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/quiz/architecture/QuizCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..06d44e70908a --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/quiz/architecture/QuizCodeStyleArchitectureTest.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.quiz.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class QuizCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".quiz"; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/architecture/ArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/architecture/ArchitectureTest.java index 2cbd4da31b86..8a6ae4a94243 100644 --- a/src/test/java/de/tum/cit/aet/artemis/shared/architecture/ArchitectureTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/shared/architecture/ArchitectureTest.java @@ -39,7 +39,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -200,25 +199,6 @@ void testJSONImplementations() { .check(allClasses); } - @Disabled // TODO: Enable this test once the restructuring is done - @Test - void testDTOImplementations() { - var dtoRecordRule = classes().that().haveSimpleNameEndingWith("DTO").and().areNotInterfaces().should().beRecords().andShould().beAnnotatedWith(JsonInclude.class) - .because("All DTOs should be records and annotated with @JsonInclude(JsonInclude.Include.NON_EMPTY)"); - var result = dtoRecordRule.evaluate(allClasses); - log.info("Current number of DTO classes: {}", result.getFailureReport().getDetails().size()); - log.info("Current DTO classes: {}", result.getFailureReport().getDetails()); - // TODO: reduce the following number to 0, if the current number is less and the test fails, decrease it - assertThat(result.getFailureReport().getDetails()).hasSize(26); - - var dtoPackageRule = classes().that().resideInAPackage("..dto").should().haveSimpleNameEndingWith("DTO"); - result = dtoPackageRule.evaluate(allClasses); - log.info("Current number of DTOs that do not end with \"DTO\": {}", result.getFailureReport().getDetails().size()); - log.info("Current DTOs that do not end with \"DTO\": {}", result.getFailureReport().getDetails()); - // TODO: reduce the following number to 0, if the current number is less and the test fails, decrease it - assertThat(result.getFailureReport().getDetails()).hasSize(32); - } - @Test void testGsonExclusion() { // TODO: Replace all uses of gson with Jackson and check that gson is not used any more diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleCodeStyleTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleCodeStyleTest.java new file mode 100644 index 000000000000..73d517a8f2ce --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleCodeStyleTest.java @@ -0,0 +1,53 @@ +package de.tum.cit.aet.artemis.shared.architecture.module; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.shared.architecture.AbstractArchitectureTest; + +public abstract class AbstractModuleCodeStyleTest extends AbstractArchitectureTest implements ModuleArchitectureTest { + + private static final Logger log = LoggerFactory.getLogger(AbstractModuleCodeStyleTest.class); + + /** + * Threshold for number of classes with name ending DTO, that are no records or not annotated with JsonInclude. + * We should aim to reduce this threshold to 0. + */ + protected int dtoAsAnnotatedRecordThreshold() { + return 0; + } + + /** + * Threshold or number of classes in a 'dto'-package which file name does not end with DTO. + * We should aim to reduce this threshold to 0. + */ + protected int dtoNameEndingThreshold() { + return 0; + } + + @Test + void testDTOImplementations() { + var dtoRecordRule = classes().that().resideInAPackage(getModuleDtoSubpackage()).and().haveSimpleNameEndingWith("DTO").and().areNotInterfaces().should().beRecords() + .andShould().beAnnotatedWith(JsonInclude.class).because("All DTOs should be records and annotated with @JsonInclude(JsonInclude.Include.NON_EMPTY)"); + var result = dtoRecordRule.allowEmptyShould(true).evaluate(allClasses); + log.info("Current number of DTO classes: {}", result.getFailureReport().getDetails().size()); + log.info("Current DTO classes: {}", result.getFailureReport().getDetails()); + assertThat(result.getFailureReport().getDetails()).hasSize(dtoAsAnnotatedRecordThreshold()); + + var dtoPackageRule = classes().that().resideInAPackage(getModuleDtoSubpackage()).should().haveSimpleNameEndingWith("DTO"); + result = dtoPackageRule.allowEmptyShould(true).evaluate(allClasses); + log.info("Current number of DTOs that do not end with \"DTO\": {}", result.getFailureReport().getDetails().size()); + log.info("Current DTOs that do not end with \"DTO\": {}", result.getFailureReport().getDetails()); + assertThat(result.getFailureReport().getDetails()).hasSize(dtoNameEndingThreshold()); + } + + private String getModuleDtoSubpackage() { + return getModulePackage() + "..dto.."; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/text/architecture/TextCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/text/architecture/TextCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..3733922c44da --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/text/architecture/TextCodeStyleArchitectureTest.java @@ -0,0 +1,21 @@ +package de.tum.cit.aet.artemis.text.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class TextCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".text"; + } + + @Override + protected int dtoAsAnnotatedRecordThreshold() { + return 1; + } + + @Override + protected int dtoNameEndingThreshold() { + return 1; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/tutorialgroup/architecture/TutorialGroupCodeStyleArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/tutorialgroup/architecture/TutorialGroupCodeStyleArchitectureTest.java new file mode 100644 index 000000000000..4bf807b83f89 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/tutorialgroup/architecture/TutorialGroupCodeStyleArchitectureTest.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.tutorialgroup.architecture; + +import de.tum.cit.aet.artemis.shared.architecture.module.AbstractModuleCodeStyleTest; + +class TutorialGroupCodeStyleArchitectureTest extends AbstractModuleCodeStyleTest { + + @Override + public String getModulePackage() { + return ARTEMIS_PACKAGE + ".tutorialgroup"; + } +} diff --git a/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts b/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts index 94e72e39d3de..db8195309ef2 100644 --- a/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts +++ b/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts @@ -21,7 +21,7 @@ import { HttpResponse } from '@angular/common/http'; import { By } from '@angular/platform-browser'; import { CompetencyRecommendationDetailComponent } from 'app/course/competencies/generate-competencies/competency-recommendation-detail.component'; import { DocumentationButtonComponent } from 'app/shared/components/documentation-button/documentation-button.component'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { IrisStageStateDTO } from 'app/entities/iris/iris-stage-dto.model'; import { CourseDescriptionFormComponent } from 'app/course/competencies/generate-competencies/course-description-form.component'; import { CourseCompetencyService } from 'app/course/competencies/course-competency.service'; @@ -57,7 +57,7 @@ describe('GenerateCompetenciesComponent', () => { }, { provide: Router, useClass: MockRouter }, { - provide: JhiWebsocketService, + provide: WebsocketService, useValue: { subscribe: jest.fn(), receive: jest.fn(() => mockWebSocketSubject.asObservable()), diff --git a/src/test/javascript/spec/component/connection-warning/connection-warning.spec.ts b/src/test/javascript/spec/component/connection-warning/connection-warning.spec.ts index 94387b6668c3..d2fab2d8908e 100644 --- a/src/test/javascript/spec/component/connection-warning/connection-warning.spec.ts +++ b/src/test/javascript/spec/component/connection-warning/connection-warning.spec.ts @@ -2,7 +2,7 @@ import { ArtemisTestModule } from '../../test.module'; import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { BehaviorSubject } from 'rxjs'; import { JhiConnectionWarningComponent } from 'app/shared/connection-warning/connection-warning.component'; -import { ConnectionState, JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { ConnectionState, WebsocketService } from 'app/core/websocket/websocket.service'; import { By } from '@angular/platform-browser'; describe('ConnectionWarning', () => { @@ -16,7 +16,7 @@ describe('ConnectionWarning', () => { imports: [ArtemisTestModule], providers: [ { - provide: JhiWebsocketService, + provide: WebsocketService, useValue: { connectionState: subject.asObservable(), }, diff --git a/src/test/javascript/spec/component/course/course-overview.component.spec.ts b/src/test/javascript/spec/component/course/course-overview.component.spec.ts index 1ea458f9426b..49a92b5402fc 100644 --- a/src/test/javascript/spec/component/course/course-overview.component.spec.ts +++ b/src/test/javascript/spec/component/course/course-overview.component.spec.ts @@ -20,7 +20,7 @@ import { MockRouter } from '../../helpers/mocks/mock-router'; import { SecuredImageComponent } from 'app/shared/image/secured-image.component'; import { OrionFilterDirective } from 'app/shared/orion/orion-filter.directive'; import { TeamService } from 'app/exercises/shared/team/team.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { SortDirective } from 'app/shared/sort/sort.directive'; @@ -137,7 +137,7 @@ describe('CourseOverviewComponent', () => { let teamService: TeamService; let tutorialGroupsService: TutorialGroupsService; let tutorialGroupsConfigurationService: TutorialGroupsConfigurationService; - let jhiWebsocketService: JhiWebsocketService; + let jhiWebsocketService: WebsocketService; let courseAccessStorageService: CourseAccessStorageService; let router: MockRouter; let jhiWebsocketServiceReceiveStub: jest.SpyInstance; @@ -192,7 +192,7 @@ describe('CourseOverviewComponent', () => { MockProvider(CourseExerciseService), MockProvider(CompetencyService), MockProvider(TeamService), - MockProvider(JhiWebsocketService), + MockProvider(WebsocketService), MockProvider(ArtemisServerDateService), MockProvider(AlertService), MockProvider(ChangeDetectorRef), @@ -222,7 +222,7 @@ describe('CourseOverviewComponent', () => { teamService = TestBed.inject(TeamService); tutorialGroupsService = TestBed.inject(TutorialGroupsService); tutorialGroupsConfigurationService = TestBed.inject(TutorialGroupsConfigurationService); - jhiWebsocketService = TestBed.inject(JhiWebsocketService); + jhiWebsocketService = TestBed.inject(WebsocketService); courseAccessStorageService = TestBed.inject(CourseAccessStorageService); metisConversationService = fixture.debugElement.injector.get(MetisConversationService); itemsDrop = component.itemsDrop; diff --git a/src/test/javascript/spec/component/exam/manage/exams/exam-checklist.component.spec.ts b/src/test/javascript/spec/component/exam/manage/exams/exam-checklist.component.spec.ts index 814df06dd42b..bec6bd9498dd 100644 --- a/src/test/javascript/spec/component/exam/manage/exams/exam-checklist.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/exams/exam-checklist.component.spec.ts @@ -11,7 +11,7 @@ import { ArtemisTestModule } from '../../../../test.module'; import { ExamChecklistService } from 'app/exam/manage/exams/exam-checklist-component/exam-checklist.service'; import { MockExamChecklistService } from '../../../../helpers/mocks/service/mock-exam-checklist.service'; import { of } from 'rxjs'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../../../helpers/mocks/service/mock-websocket.service'; import { ExamEditWorkingTimeComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component'; @@ -67,7 +67,7 @@ describe('ExamChecklistComponent', () => { ], providers: [ { provide: ExamChecklistService, useClass: MockExamChecklistService }, - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, ], }) .compileComponents() diff --git a/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts b/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts index 811b2574c994..586bba32db3c 100644 --- a/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts @@ -28,7 +28,7 @@ import { DeleteButtonDirective } from 'app/shared/delete-dialog/delete-button.di import { MockAccountService } from '../../../../helpers/mocks/service/mock-account.service'; import { AlertService } from 'app/core/util/alert.service'; import { ArtemisDurationFromSecondsPipe } from 'app/shared/pipes/artemis-duration-from-seconds.pipe'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../../../helpers/mocks/service/mock-websocket.service'; import { ExamEditWorkingTimeComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component'; import { ExamLiveAnnouncementCreateButtonComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-announcement-dialog/exam-live-announcement-create-button.component'; @@ -118,7 +118,7 @@ describe('ExamDetailComponent', () => { safeHtmlForMarkdown: () => exampleHTML, }), MockProvider(AlertService), - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, { provide: TranslateService, useClass: MockTranslateService }, { provide: LocalStorageService, useClass: MockLocalStorageService }, MockProvider(ArtemisDurationFromSecondsPipe), diff --git a/src/test/javascript/spec/component/exam/manage/student-exams/exam-status.component.spec.ts b/src/test/javascript/spec/component/exam/manage/student-exams/exam-status.component.spec.ts index 453df0f6be55..d3de065f45a5 100644 --- a/src/test/javascript/spec/component/exam/manage/student-exams/exam-status.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/student-exams/exam-status.component.spec.ts @@ -13,7 +13,7 @@ import { MockExamChecklistService } from '../../../../helpers/mocks/service/mock import { ExamChecklist } from 'app/entities/exam/exam-checklist.model'; import { of } from 'rxjs'; import { Course } from 'app/entities/course.model'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../../../helpers/mocks/service/mock-websocket.service'; enum DateOffsetType { @@ -61,7 +61,7 @@ describe('ExamStatusComponent', () => { providers: [ { provide: TranslateService, useClass: MockTranslateService }, { provide: ExamChecklistService, useClass: MockExamChecklistService }, - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, ], }) .compileComponents() diff --git a/src/test/javascript/spec/component/exam/manage/student-exams/student-exams.component.spec.ts b/src/test/javascript/spec/component/exam/manage/student-exams/student-exams.component.spec.ts index 6212f37d7a93..6be540969bb9 100644 --- a/src/test/javascript/spec/component/exam/manage/student-exams/student-exams.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/student-exams/student-exams.component.spec.ts @@ -23,7 +23,7 @@ import { MockAccountService } from '../../../../helpers/mocks/service/mock-accou import { AlertService } from 'app/core/util/alert.service'; import { TranslateDirective } from 'app/shared/language/translate.directive'; import { MockTranslateService } from '../../../../helpers/mocks/service/mock-translate.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../../../helpers/mocks/service/mock-websocket.service'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { ArtemisTestModule } from '../../../../test.module'; @@ -165,7 +165,7 @@ describe('StudentExamsComponent', () => { }, { provide: AccountService, useClass: MockAccountService }, { provide: TranslateService, useClass: MockTranslateService }, - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, { provide: NgbModal, useClass: MockNgbModalService }, MockProvider(ProfileService, { getProfileInfo: () => of({ activeProfiles: [] }) }, 'useValue'), ]; diff --git a/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts b/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts index 1e4bda79525a..90384410cd51 100644 --- a/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts @@ -5,7 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { UMLDiagramType } from '@ls1intum/apollon'; import { TranslateService } from '@ngx-translate/core'; import { AlertService } from 'app/core/util/alert.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { CourseStorageService } from 'app/course/manage/course-storage.service'; import { Course } from 'app/entities/course.model'; @@ -114,7 +114,7 @@ describe('ExamParticipationComponent', () => { MockPipe(ArtemisDatePipe), ], providers: [ - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, { provide: LocalStorageService, useClass: MockLocalStorageService }, { provide: ActivatedRoute, diff --git a/src/test/javascript/spec/component/exercises/quiz/quiz-participation.component.spec.ts b/src/test/javascript/spec/component/exercises/quiz/quiz-participation.component.spec.ts index e0df4e004522..0a2b061d9f94 100644 --- a/src/test/javascript/spec/component/exercises/quiz/quiz-participation.component.spec.ts +++ b/src/test/javascript/spec/component/exercises/quiz/quiz-participation.component.spec.ts @@ -3,7 +3,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; import { QuizBatch, QuizExercise, QuizMode } from 'app/entities/quiz/quiz-exercise.model'; import { QuizQuestion, QuizQuestionType } from 'app/entities/quiz/quiz-question.model'; @@ -151,7 +151,7 @@ describe('QuizParticipationComponent', () => { .provide({ provide: TranslateService, useClass: MockTranslateService }) .provide({ provide: LocalStorageService, useClass: MockLocalStorageService }) .provide({ provide: SessionStorageService, useClass: MockSyncStorage }) - .provide({ provide: JhiWebsocketService, useClass: MockWebsocketService }) + .provide({ provide: WebsocketService, useClass: MockWebsocketService }) .provide({ provide: ActivatedRoute, useValue: { @@ -518,7 +518,7 @@ describe('QuizParticipationComponent', () => { .provide({ provide: TranslateService, useClass: MockTranslateService }) .provide({ provide: LocalStorageService, useClass: MockLocalStorageService }) .provide({ provide: SessionStorageService, useClass: MockSyncStorage }) - .provide({ provide: JhiWebsocketService, useClass: MockWebsocketService }) + .provide({ provide: WebsocketService, useClass: MockWebsocketService }) .provide({ provide: ActivatedRoute, useValue: { @@ -591,7 +591,7 @@ describe('QuizParticipationComponent', () => { .keep(AlertService) .keep(ArtemisServerDateService) .keep(QuizExerciseService) - .mock(JhiWebsocketService) + .mock(WebsocketService) .provide(provideHttpClient()) .provide(provideHttpClientTesting()) .provide({ provide: TranslateService, useClass: MockTranslateService }) @@ -680,7 +680,7 @@ describe('QuizParticipationComponent', () => { .provide({ provide: TranslateService, useClass: MockTranslateService }) .provide({ provide: LocalStorageService, useClass: MockLocalStorageService }) .provide({ provide: SessionStorageService, useClass: MockSyncStorage }) - .provide({ provide: JhiWebsocketService, useClass: MockWebsocketService }) + .provide({ provide: WebsocketService, useClass: MockWebsocketService }) .provide({ provide: ActivatedRoute, useValue: { diff --git a/src/test/javascript/spec/component/exercises/shared/team-submission-sync.component.spec.ts b/src/test/javascript/spec/component/exercises/shared/team-submission-sync.component.spec.ts index 6bab9646a59b..422551bf318a 100644 --- a/src/test/javascript/spec/component/exercises/shared/team-submission-sync.component.spec.ts +++ b/src/test/javascript/spec/component/exercises/shared/team-submission-sync.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TeamSubmissionSyncComponent } from 'app/exercises/shared/team-submission-sync/team-submission-sync.component'; import { MockProvider } from 'ng-mocks'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MockAccountService } from '../../../helpers/mocks/service/mock-account.service'; import { ExerciseType } from 'app/entities/exercise.model'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; @@ -24,7 +24,7 @@ import { SubmissionPatch } from 'app/entities/submission-patch.model'; describe('Team Submission Sync Component', () => { let fixture: ComponentFixture; let component: TeamSubmissionSyncComponent; - let websocketService: JhiWebsocketService; + let websocketService: WebsocketService; let textSubmissionWithParticipation: Submission; let submissionObservableWithParticipation: Observable; let submissionSyncPayload: SubmissionSyncPayload; @@ -35,7 +35,7 @@ describe('Team Submission Sync Component', () => { providers: [ MockProvider(AlertService), MockProvider(SessionStorageService), - MockProvider(JhiWebsocketService), + MockProvider(WebsocketService), { provide: AccountService, useClass: MockAccountService }, { provide: TranslateService, useClass: MockTranslateService }, { provide: HttpClient, useClass: MockHttpService }, @@ -46,7 +46,7 @@ describe('Team Submission Sync Component', () => { fixture = TestBed.createComponent(TeamSubmissionSyncComponent); component = fixture.componentInstance; - websocketService = TestBed.inject(JhiWebsocketService); + websocketService = TestBed.inject(WebsocketService); component.exerciseType = ExerciseType.TEXT; const participation = new StudentParticipation(ParticipationType.STUDENT); diff --git a/src/test/javascript/spec/component/iris/iris-status.service.spec.ts b/src/test/javascript/spec/component/iris/iris-status.service.spec.ts index 54f7ee7ced58..c3c06455d960 100644 --- a/src/test/javascript/spec/component/iris/iris-status.service.spec.ts +++ b/src/test/javascript/spec/component/iris/iris-status.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { of } from 'rxjs'; import { IrisStatusService } from 'app/iris/iris-status.service'; import { IrisRateLimitInformation } from 'app/entities/iris/iris-ratelimit-info.model'; @@ -17,7 +17,7 @@ describe('IrisStatusService', () => { provideHttpClient(), provideHttpClientTesting(), IrisStatusService, - { provide: JhiWebsocketService, useValue: { connectionState: of({ connected: true, intendedDisconnect: false, wasEverConnectedBefore: true }) } }, + { provide: WebsocketService, useValue: { connectionState: of({ connected: true, intendedDisconnect: false, wasEverConnectedBefore: true }) } }, ], }); diff --git a/src/test/javascript/spec/component/iris/websocket.service.spec.ts b/src/test/javascript/spec/component/iris/websocket.service.spec.ts deleted file mode 100644 index b1f317220d9c..000000000000 --- a/src/test/javascript/spec/component/iris/websocket.service.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; -import { MockProvider } from 'ng-mocks'; -import { AccountService } from 'app/core/auth/account.service'; -import { MockAccountService } from '../../helpers/mocks/service/mock-account.service'; -import { IrisWebsocketService } from 'app/iris/iris-websocket.service'; -import { defer, of } from 'rxjs'; -import { provideHttpClient } from '@angular/common/http'; - -describe('IrisWebsocketService', () => { - let irisWebsocketService: IrisWebsocketService; - let jhiWebsocketService: JhiWebsocketService; - - const sessionId = 1; - const channel = `/user/topic/iris/${sessionId}`; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - IrisWebsocketService, - MockProvider(JhiWebsocketService), - { provide: AccountService, useClass: MockAccountService }, - ], - }); - irisWebsocketService = TestBed.inject(IrisWebsocketService); - jhiWebsocketService = TestBed.inject(JhiWebsocketService); - }); - - afterEach(() => { - irisWebsocketService.ngOnDestroy(); - jest.restoreAllMocks(); - }); - - it('should subscribe to a channel', fakeAsync(() => { - const subscribeSpy = jest.spyOn(jhiWebsocketService, 'subscribe').mockReturnValue(jhiWebsocketService); - const receiveSpy = jest.spyOn(jhiWebsocketService, 'receive').mockReturnValue(of(null)); - - irisWebsocketService.subscribeToSession(sessionId); - - expect(subscribeSpy).toHaveBeenCalledWith(channel); - expect(receiveSpy).toHaveBeenCalledWith(channel); - expect(irisWebsocketService['subscribedChannels'].has(sessionId)).toBeTrue(); - })); - - it('should return an existing channel', fakeAsync(() => { - // Spy on the JhiWebsocketService's subscribe and receive methods - const subscribeSpy = jest.spyOn(jhiWebsocketService, 'subscribe').mockReturnValue(jhiWebsocketService); - const receiveSpy = jest.spyOn(jhiWebsocketService, 'receive').mockReturnValue(of(null)); - - // Call subscribeToSession for the first time - const firstObservable = irisWebsocketService.subscribeToSession(sessionId); - - // Call subscribeToSession for the second time - const secondObservable = irisWebsocketService.subscribeToSession(sessionId); - - // Check that subscribe and receive were called only once - expect(subscribeSpy).toHaveBeenCalledOnce(); - expect(receiveSpy).toHaveBeenCalledOnce(); - - // Check that the same observable was returned both times - expect(firstObservable).toStrictEqual(secondObservable); - })); - - it('should emit a message', fakeAsync(() => { - const testMessage = 'Test message'; - - // Spy on the JhiWebsocketService's subscribe and receive methods - const subscribeSpy = jest.spyOn(jhiWebsocketService, 'subscribe').mockReturnValue(jhiWebsocketService); - const receiveSpy = jest.spyOn(jhiWebsocketService, 'receive').mockReturnValue(defer(() => Promise.resolve(testMessage))); - - // Call subscribeToSession and subscribe to the returned observable - const observable = irisWebsocketService.subscribeToSession(sessionId); - let receivedMessage: any; - observable.subscribe((message) => { - // Store the message emitted by the observable - receivedMessage = message; - }); - tick(); - expect(receivedMessage).toEqual(testMessage); - // Check that subscribe and receive were called with the correct channel - expect(subscribeSpy).toHaveBeenCalledWith(channel); - expect(receiveSpy).toHaveBeenCalledWith(channel); - })); - - it('should unsubscribe from a channel', fakeAsync(() => { - jest.spyOn(jhiWebsocketService, 'subscribe').mockReturnValue(jhiWebsocketService); - jest.spyOn(jhiWebsocketService, 'receive').mockReturnValue(of(null)); - const unsubscribeSpy = jest.spyOn(jhiWebsocketService, 'unsubscribe'); - - irisWebsocketService.subscribeToSession(sessionId); - expect(irisWebsocketService['subscribedChannels'].has(sessionId)).toBeTrue(); - - const result = irisWebsocketService.unsubscribeFromSession(sessionId); - - expect(unsubscribeSpy).toHaveBeenCalledWith(channel); - - // Check that the sessionId was removed from the subscribedChannels map - expect(irisWebsocketService['subscribedChannels'].has(sessionId)).toBeFalse(); - - // Check that the method returned true - expect(result).toBeTrue(); - })); -}); diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts index 7224aaf85123..a68424ec4ef0 100644 --- a/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts +++ b/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; import { of, throwError } from 'rxjs'; import { BuildJob } from 'app/entities/programming/build-job.model'; @@ -125,7 +125,7 @@ describe('BuildAgentDetailsComponent', () => { imports: [ArtemisTestModule], declarations: [], providers: [ - { provide: JhiWebsocketService, useValue: mockWebsocketService }, + { provide: WebsocketService, useValue: mockWebsocketService }, { provide: ActivatedRoute, useValue: new MockActivatedRoute({ key: 'ABC123' }) }, { provide: BuildAgentsService, useValue: mockBuildAgentsService }, { provide: DataTableComponent, useClass: DataTableComponent }, diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts index 623f92e6482f..40967076b0c9 100644 --- a/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts +++ b/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { BuildAgentSummaryComponent } from 'app/localci/build-agents/build-agent-summary/build-agent-summary.component'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; import { of, throwError } from 'rxjs'; import { BuildJob } from 'app/entities/programming/build-job.model'; @@ -143,7 +143,7 @@ describe('BuildAgentSummaryComponent', () => { imports: [ArtemisTestModule], declarations: [], providers: [ - { provide: JhiWebsocketService, useValue: mockWebsocketService }, + { provide: WebsocketService, useValue: mockWebsocketService }, { provide: BuildAgentsService, useValue: mockBuildAgentsService }, { provide: DataTableComponent, useClass: DataTableComponent }, MockProvider(AlertService), diff --git a/src/test/javascript/spec/component/markdown-editor/markdown-editor-monaco.component.spec.ts b/src/test/javascript/spec/component/markdown-editor/markdown-editor-monaco.component.spec.ts index 8a9c240182e6..3f4ef537db0f 100644 --- a/src/test/javascript/spec/component/markdown-editor/markdown-editor-monaco.component.spec.ts +++ b/src/test/javascript/spec/component/markdown-editor/markdown-editor-monaco.component.spec.ts @@ -317,4 +317,68 @@ describe('MarkdownEditorMonacoComponent', () => { comp.applyOptionPreset(preset); expect(applySpy).toHaveBeenCalledExactlyOnceWith(preset); }); + + it('should render markdown callouts correctly', () => { + comp._markdown = ` +> [!NOTE] +> Highlights information that users should take into account, even when skimming. + +> [!TIP] +> Optional information to help a user be more successful. + +> [!IMPORTANT] +> Crucial information necessary for users to succeed. + +> [!WARNING] +> Critical content demanding immediate user attention due to potential risks. + +> [!CAUTION] +> Negative potential consequences of an action.`; + + const expectedHtml = `

Note

Highlights information that users should take into account, even when skimming.

+
+

Tip

Optional information to help a user be more successful.

+
+

Important

Crucial information necessary for users to succeed.

+
+

Warning

Critical content demanding immediate user attention due to potential risks.

+
+

Caution

Negative potential consequences of an action.

+
`; + comp.parseMarkdown(); + // The markdown editor generates SafeHtml to prevent certain client-side attacks, but for this test, we only need the raw HTML. + const html = comp.defaultPreviewHtml as { changingThisBreaksApplicationSecurity: string }; + const renderedHtml = html.changingThisBreaksApplicationSecurity; + expect(renderedHtml).toEqual(expectedHtml); + }); + it('should handle invalid callout type gracefully', () => { + comp._markdown = ` +> [!INVALID] +> This is an invalid callout type.`; + comp.parseMarkdown(); + // The markdown editor generates SafeHtml to prevent certain client-side attacks, but for this test, we only need the raw HTML. + const html = comp.defaultPreviewHtml as { changingThisBreaksApplicationSecurity: string }; + const renderedHtml = html.changingThisBreaksApplicationSecurity; + expect(renderedHtml).toContain('
'); + }); + + it('should render nested content within callouts', () => { + comp._markdown = ` +> [!NOTE] +> # Heading +> - List item 1 +> - List item 2 +> +> Nested blockquote: +> > This is nested.`; + + comp.parseMarkdown(); + + const html = comp.defaultPreviewHtml as { changingThisBreaksApplicationSecurity: string }; + // The markdown editor generates SafeHtml to prevent certain client-side attacks, but for this test, we only need the raw HTML. + const renderedHtml = html.changingThisBreaksApplicationSecurity; + expect(renderedHtml).toContain('

Heading

'); + expect(renderedHtml).toContain('
    '); + expect(renderedHtml).toContain('
    '); + }); }); diff --git a/src/test/javascript/spec/component/modeling-submission/modeling-submission-team.component.spec.ts b/src/test/javascript/spec/component/modeling-submission/modeling-submission-team.component.spec.ts index 51b5296d0d41..1c299659b86e 100644 --- a/src/test/javascript/spec/component/modeling-submission/modeling-submission-team.component.spec.ts +++ b/src/test/javascript/spec/component/modeling-submission/modeling-submission-team.component.spec.ts @@ -23,7 +23,7 @@ import { Result } from 'app/entities/result.model'; import { routes } from 'app/exercises/modeling/participate/modeling-participation.route'; import { AssessmentType } from 'app/entities/assessment-type.model'; import { Feedback, FeedbackType } from 'app/entities/feedback.model'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { HtmlForMarkdownPipe } from 'app/shared/pipes/html-for-markdown.pipe'; import { UMLDiagramType, UMLElement, UMLModel } from '@ls1intum/apollon'; import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; @@ -322,7 +322,7 @@ describe('ModelingSubmissionComponent', () => { it('should update submission when new submission comes in from websocket', () => { submission.submitted = false; jest.spyOn(service, 'getLatestSubmissionForModelingEditor').mockReturnValue(of(submission)); - const websocketService = debugElement.injector.get(JhiWebsocketService); + const websocketService = debugElement.injector.get(WebsocketService); jest.spyOn(websocketService, 'subscribe'); const modelSubmission = ({ id: 1, diff --git a/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts b/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts index 603e093efca5..b665f96043b6 100644 --- a/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts +++ b/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts @@ -23,7 +23,7 @@ import { Result } from 'app/entities/result.model'; import { routes } from 'app/exercises/modeling/participate/modeling-participation.route'; import { AssessmentType } from 'app/entities/assessment-type.model'; import { Feedback, FeedbackType } from 'app/entities/feedback.model'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { HtmlForMarkdownPipe } from 'app/shared/pipes/html-for-markdown.pipe'; import { UMLDiagramType, UMLElement, UMLModel } from '@ls1intum/apollon'; import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; @@ -472,7 +472,7 @@ describe('ModelingSubmissionComponent', () => { submission.submitted = false; jest.spyOn(service, 'getLatestSubmissionForModelingEditor').mockReturnValue(of(submission)); - const websocketService = debugElement.injector.get(JhiWebsocketService); + const websocketService = debugElement.injector.get(WebsocketService); jest.spyOn(websocketService, 'subscribe'); const modelSubmission = ({ id: 1, diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.spec.ts index 553cf6158c1e..9ca8baa9a843 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component.spec.ts @@ -14,6 +14,7 @@ describe('ChannelFormComponent', () => { const validDescription = 'This is a general channel'; const validIsPublic = true; const validIsAnnouncementChannel = false; + const validIsCourseWideChannel = false; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -74,6 +75,7 @@ describe('ChannelFormComponent', () => { description: undefined, isPublic: validIsPublic, isAnnouncementChannel: validIsAnnouncementChannel, + isCourseWideChannel: validIsCourseWideChannel, }; clickSubmitButton(true, expectChannelData); @@ -96,6 +98,7 @@ describe('ChannelFormComponent', () => { description: validDescription, isPublic: validIsPublic, isAnnouncementChannel: validIsAnnouncementChannel, + isCourseWideChannel: validIsCourseWideChannel, }; clickSubmitButton(true, expectChannelData); diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts index 4f5ed13dbeff..6e51aadf2278 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts @@ -4,7 +4,7 @@ import { ChannelsCreateDialogComponent } from 'app/overview/course-conversations import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { MockPipe, MockProvider } from 'ng-mocks'; import { Course } from 'app/entities/course.model'; -import { ChannelFormData } from 'app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component'; +import { ChannelFormComponent, ChannelFormData } from 'app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component'; import { By } from '@angular/platform-browser'; import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { initializeDialog } from '../dialog-test-helpers'; @@ -38,6 +38,13 @@ describe('ChannelsCreateDialogComponent', () => { expect(component).toBeTruthy(); }); + it('should initialize the dialog correctly', () => { + const initializeSpy = jest.spyOn(component, 'initialize'); + component.initialize(); + expect(initializeSpy).toHaveBeenCalledOnce(); + expect(component.course).toBe(course); + }); + it('clicking close button in modal header should dismiss the modal', () => { const closeButton = fixture.debugElement.nativeElement.querySelector('.modal-header button'); const activeModal = TestBed.inject(NgbActiveModal); @@ -68,6 +75,13 @@ describe('ChannelsCreateDialogComponent', () => { expect(component.isAnnouncementChannel).toBeTrue(); }); + it('should change channel scope type when channel scope type is changed in channel form', () => { + expect(component.isCourseWideChannel).toBeFalse(); + const form: ChannelFormComponent = fixture.debugElement.query(By.directive(ChannelFormComponent)).componentInstance; + form.isCourseWideChannelChanged.emit(true); + expect(component.isCourseWideChannel).toBeTrue(); + }); + it('should close modal with the channel to create when form is submitted', () => { const activeModal = TestBed.inject(NgbActiveModal); const closeSpy = jest.spyOn(activeModal, 'close'); @@ -91,4 +105,46 @@ describe('ChannelsCreateDialogComponent', () => { expect(closeSpy).toHaveBeenCalledOnce(); expect(closeSpy).toHaveBeenCalledWith(expectedChannel); }); + + it('should call createChannel with correct data', () => { + const createChannelSpy = jest.spyOn(component, 'createChannel'); + + const formData: ChannelFormData = { + name: 'testChannel', + description: 'Test description', + isPublic: false, + isAnnouncementChannel: true, + isCourseWideChannel: false, + }; + + const form: ChannelFormComponent = fixture.debugElement.query(By.directive(ChannelFormComponent)).componentInstance; + form.formSubmitted.emit(formData); + + expect(createChannelSpy).toHaveBeenCalledOnce(); + expect(createChannelSpy).toHaveBeenCalledWith(formData); + }); + + it('should close modal when createChannel is called', () => { + const activeModal = TestBed.inject(NgbActiveModal); + const closeSpy = jest.spyOn(activeModal, 'close'); + + const formData: ChannelFormData = { + name: 'testChannel', + description: 'Test description', + isPublic: true, + isAnnouncementChannel: false, + isCourseWideChannel: true, + }; + + component.createChannel(formData); + + expect(closeSpy).toHaveBeenCalledOnce(); + expect(closeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: formData.name, + description: formData.description, + isPublic: formData.isPublic, + }), + ); + }); }); diff --git a/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts index d707c727fbe7..287e4deb3d45 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts @@ -5,7 +5,7 @@ import { MockProvider } from 'ng-mocks'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { OneToOneChatService } from 'app/shared/metis/conversations/one-to-one-chat.service'; import { AlertService } from 'app/core/util/alert.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { Course } from 'app/entities/course.model'; import { ConversationService } from 'app/shared/metis/conversations/conversation.service'; import { ChannelService } from 'app/shared/metis/conversations/channel.service'; @@ -32,7 +32,7 @@ describe('MetisConversationService', () => { let groupChatService: GroupChatService; let oneToOneChatService: OneToOneChatService; let channelService: ChannelService; - let websocketService: JhiWebsocketService; + let websocketService: WebsocketService; let courseManagementService: CourseManagementService; let alertService: AlertService; let notificationService: NotificationService; @@ -53,7 +53,7 @@ describe('MetisConversationService', () => { MockProvider(ChannelService), MockProvider(OneToOneChatService), MockProvider(ConversationService), - MockProvider(JhiWebsocketService), + MockProvider(WebsocketService), MockProvider(AlertService), { provide: NotificationService, useClass: MockNotificationService }, { provide: AccountService, useClass: MockAccountService }, @@ -71,7 +71,7 @@ describe('MetisConversationService', () => { groupChatService = TestBed.inject(GroupChatService); oneToOneChatService = TestBed.inject(OneToOneChatService); channelService = TestBed.inject(ChannelService); - websocketService = TestBed.inject(JhiWebsocketService); + websocketService = TestBed.inject(WebsocketService); courseManagementService = TestBed.inject(CourseManagementService); conversationService = TestBed.inject(ConversationService); alertService = TestBed.inject(AlertService); diff --git a/src/test/javascript/spec/component/plagiarism/plagiarism-inspector.component.spec.ts b/src/test/javascript/spec/component/plagiarism/plagiarism-inspector.component.spec.ts index 1a90ec18ecd3..d529682dca13 100644 --- a/src/test/javascript/spec/component/plagiarism/plagiarism-inspector.component.spec.ts +++ b/src/test/javascript/spec/component/plagiarism/plagiarism-inspector.component.spec.ts @@ -15,7 +15,7 @@ import { TextExercise } from 'app/entities/text/text-exercise.model'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; import { ProgrammingExerciseService } from 'app/exercises/programming/manage/services/programming-exercise.service'; import { TextPlagiarismResult } from 'app/exercises/shared/plagiarism/types/text/TextPlagiarismResult'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { PlagiarismInspectorService } from 'app/exercises/shared/plagiarism/plagiarism-inspector/plagiarism-inspector.service'; import { PlagiarismComparison } from 'app/exercises/shared/plagiarism/types/PlagiarismComparison'; @@ -116,7 +116,7 @@ describe('Plagiarism Inspector Component', () => { }); it('should register to topic and fetch latest results on init', fakeAsync(() => { - const websocketService = TestBed.inject(JhiWebsocketService); + const websocketService = TestBed.inject(WebsocketService); const websocketServiceSpy = jest.spyOn(websocketService, 'subscribe'); jest.spyOn(websocketService, 'receive').mockReturnValue(of({ state: 'COMPLETED', messages: 'a message' } as PlagiarismCheckState)); jest.spyOn(modelingExerciseService, 'getLatestPlagiarismResult').mockReturnValue(of(modelingPlagiarismResultDTO)); diff --git a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts index 80ef5cf80a23..eeae40d0f971 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts @@ -36,8 +36,10 @@ import { TextEditorRange } from 'app/shared/monaco-editor/model/actions/adapter/ import { TextEditorPosition } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-position.model'; import { BulletedListAction } from 'app/shared/monaco-editor/model/actions/bulleted-list.action'; import { OrderedListAction } from 'app/shared/monaco-editor/model/actions/ordered-list.action'; -import { ListAction } from '../../../../../../../main/webapp/app/shared/monaco-editor/model/actions/list.action'; +import { ListAction } from 'app/shared/monaco-editor/model/actions/list.action'; import monaco from 'monaco-editor'; +import { FileService } from 'app/shared/http/file.service'; +import { MockFileService } from '../../../../helpers/mocks/service/mock-file.service'; describe('PostingsMarkdownEditor', () => { let component: PostingMarkdownEditorComponent; @@ -45,6 +47,7 @@ describe('PostingsMarkdownEditor', () => { let debugElement: DebugElement; let mockMarkdownEditorComponent: MarkdownEditorMonacoComponent; let metisService: MetisService; + let fileService: FileService; let lectureService: LectureService; let findLectureWithDetailsSpy: jest.SpyInstance; @@ -120,6 +123,7 @@ describe('PostingsMarkdownEditor', () => { return TestBed.configureTestingModule({ providers: [ { provide: MetisService, useClass: MockMetisService }, + { provide: FileService, useClass: MockFileService }, MockProvider(LectureService), MockProvider(CourseManagementService), MockProvider(ChannelService), @@ -134,6 +138,7 @@ describe('PostingsMarkdownEditor', () => { fixture = TestBed.createComponent(PostingMarkdownEditorComponent); component = fixture.componentInstance; debugElement = fixture.debugElement; + fileService = TestBed.inject(FileService); metisService = TestBed.inject(MetisService); lectureService = TestBed.inject(LectureService); @@ -154,14 +159,14 @@ describe('PostingsMarkdownEditor', () => { containDefaultActions(component.defaultActions); expect(component.defaultActions).toEqual(expect.arrayContaining([expect.any(UserMentionAction), expect.any(ChannelReferenceAction)])); - expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService)); + expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService, fileService)); }); it('should have set the correct default commands on init if communication is disabled', () => { jest.spyOn(CourseModel, 'isCommunicationEnabled').mockReturnValueOnce(false); component.ngOnInit(); containDefaultActions(component.defaultActions); - expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService)); + expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService, fileService)); }); function containDefaultActions(defaultActions: TextEditorAction[]) { @@ -186,7 +191,7 @@ describe('PostingsMarkdownEditor', () => { component.ngOnInit(); containDefaultActions(component.defaultActions); expect(component.defaultActions).toEqual(expect.arrayContaining([expect.any(FaqReferenceAction)])); - expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService)); + expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService, fileService)); }); it('should have set the correct default commands on init if faq is disabled', () => { @@ -194,7 +199,7 @@ describe('PostingsMarkdownEditor', () => { component.ngOnInit(); containDefaultActions(component.defaultActions); expect(component.defaultActions).toEqual(expect.not.arrayContaining([expect.any(FaqReferenceAction)])); - expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService)); + expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService, fileService)); }); it('should show the correct amount of characters below the markdown input', () => { diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts index 9b1c935ded18..f2b0318ab3e5 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts @@ -30,11 +30,14 @@ import { Attachment } from 'app/entities/attachment.model'; import dayjs from 'dayjs/esm'; import { FaqReferenceAction } from 'app/shared/monaco-editor/model/actions/communication/faq-reference.action'; import { Faq } from 'app/entities/faq.model'; +import { FileService } from 'app/shared/http/file.service'; +import { MockFileService } from '../../../helpers/mocks/service/mock-file.service'; describe('MonacoEditorCommunicationActionIntegration', () => { let comp: MonacoEditorComponent; let fixture: ComponentFixture; let metisService: MetisService; + let fileService: FileService; let courseManagementService: CourseManagementService; let channelService: ChannelService; let lectureService: LectureService; @@ -46,34 +49,34 @@ describe('MonacoEditorCommunicationActionIntegration', () => { let exerciseReferenceAction: ExerciseReferenceAction; let faqReferenceAction: FaqReferenceAction; - beforeEach(() => { - return TestBed.configureTestingModule({ + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [MonacoEditorComponent], providers: [ { provide: MetisService, useClass: MockMetisService }, + { provide: FileService, useClass: MockFileService }, { provide: TranslateService, useClass: MockTranslateService }, { provide: LocalStorageService, useClass: MockLocalStorageService }, MockProvider(LectureService), MockProvider(CourseManagementService), MockProvider(ChannelService), ], - }) - .compileComponents() - .then(() => { - global.ResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => { - return new MockResizeObserver(callback); - }); - fixture = TestBed.createComponent(MonacoEditorComponent); - comp = fixture.componentInstance; - metisService = TestBed.inject(MetisService); - courseManagementService = TestBed.inject(CourseManagementService); - lectureService = TestBed.inject(LectureService); - channelService = TestBed.inject(ChannelService); - channelReferenceAction = new ChannelReferenceAction(metisService, channelService); - userMentionAction = new UserMentionAction(courseManagementService, metisService); - exerciseReferenceAction = new ExerciseReferenceAction(metisService); - faqReferenceAction = new FaqReferenceAction(metisService); - }); + }).compileComponents(); + + global.ResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => { + return new MockResizeObserver(callback); + }); + fixture = TestBed.createComponent(MonacoEditorComponent); + comp = fixture.componentInstance; + metisService = TestBed.inject(MetisService); + fileService = TestBed.inject(FileService); + courseManagementService = TestBed.inject(CourseManagementService); + lectureService = TestBed.inject(LectureService); + channelService = TestBed.inject(ChannelService); + channelReferenceAction = new ChannelReferenceAction(metisService, channelService); + userMentionAction = new UserMentionAction(courseManagementService, metisService); + exerciseReferenceAction = new ExerciseReferenceAction(metisService); + faqReferenceAction = new FaqReferenceAction(metisService); }); afterEach(() => { @@ -280,7 +283,7 @@ describe('MonacoEditorCommunicationActionIntegration', () => { beforeEach(() => { lectures = metisService.getCourse().lectures!; jest.spyOn(lectureService, 'findAllByCourseIdWithSlides').mockReturnValue(of(new HttpResponse({ body: lectures, status: 200 }))); - lectureAttachmentReferenceAction = new LectureAttachmentReferenceAction(metisService, lectureService); + lectureAttachmentReferenceAction = new LectureAttachmentReferenceAction(metisService, lectureService, fileService); }); afterEach(() => { @@ -295,7 +298,10 @@ describe('MonacoEditorCommunicationActionIntegration', () => { id: lecture.id!, title: lecture.title!, attachmentUnits: lecture.lectureUnits?.filter((unit) => unit.type === LectureUnitType.ATTACHMENT), - attachments: lecture.attachments, + attachments: lecture.attachments?.map((attachment) => ({ + ...attachment, + link: attachment.link && attachment.name ? fileService.createAttachmentFileUrl(attachment.link, attachment.name, false) : attachment.link, + })), })); expect(lectureAttachmentReferenceAction.lecturesWithDetails).toEqual(lecturesWithDetails); diff --git a/src/test/javascript/spec/component/shared/notification/system-notification.component.spec.ts b/src/test/javascript/spec/component/shared/notification/system-notification.component.spec.ts index 9734bf8279ec..dbadfc93e848 100644 --- a/src/test/javascript/spec/component/shared/notification/system-notification.component.spec.ts +++ b/src/test/javascript/spec/component/shared/notification/system-notification.component.spec.ts @@ -9,14 +9,14 @@ import { ArtemisTestModule } from '../../../test.module'; import { MockSyncStorage } from '../../../helpers/mocks/service/mock-sync-storage.service'; import { MockAccountService } from '../../../helpers/mocks/service/mock-account.service'; import { SystemNotification, SystemNotificationType } from 'app/entities/system-notification.model'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../../helpers/mocks/service/mock-websocket.service'; describe('System Notification Component', () => { let systemNotificationComponent: SystemNotificationComponent; let systemNotificationComponentFixture: ComponentFixture; let systemNotificationService: SystemNotificationService; - let jhiWebsocketService: JhiWebsocketService; + let jhiWebsocketService: WebsocketService; const createActiveNotification = (type: SystemNotificationType, id: number) => { return { @@ -47,7 +47,7 @@ describe('System Notification Component', () => { { provide: LocalStorageService, useClass: MockSyncStorage }, { provide: SessionStorageService, useClass: MockSyncStorage }, { provide: AccountService, useClass: MockAccountService }, - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, ], }) .compileComponents() @@ -55,7 +55,7 @@ describe('System Notification Component', () => { systemNotificationComponentFixture = TestBed.createComponent(SystemNotificationComponent); systemNotificationComponent = systemNotificationComponentFixture.componentInstance; systemNotificationService = TestBed.inject(SystemNotificationService); - jhiWebsocketService = TestBed.inject(JhiWebsocketService); + jhiWebsocketService = TestBed.inject(WebsocketService); }); }); diff --git a/src/test/javascript/spec/component/shared/unreferenced-feedback.component.spec.ts b/src/test/javascript/spec/component/shared/unreferenced-feedback.component.spec.ts index f4ef3363ae91..dfddf6ced175 100644 --- a/src/test/javascript/spec/component/shared/unreferenced-feedback.component.spec.ts +++ b/src/test/javascript/spec/component/shared/unreferenced-feedback.component.spec.ts @@ -2,11 +2,11 @@ import { ArtemisTestModule } from '../../test.module'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { UnreferencedFeedbackComponent } from 'app/exercises/shared/unreferenced-feedback/unreferenced-feedback.component'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; -import { MockComponent, MockPipe } from 'ng-mocks'; +import { MockPipe } from 'ng-mocks'; import { Feedback, FeedbackType } from 'app/entities/feedback.model'; -import { GradingInstruction } from 'app/exercises/shared/structured-grading-criterion/grading-instruction.model'; -import { UnreferencedFeedbackDetailComponent } from 'app/assessment/unreferenced-feedback-detail/unreferenced-feedback-detail.component'; import { StructuredGradingCriterionService } from 'app/exercises/shared/structured-grading-criterion/structured-grading-criterion.service'; +import { By } from '@angular/platform-browser'; +import { UnreferencedFeedbackDetailStubComponent } from '../../helpers/stubs/unreferenced-feedback-detail-stub.component'; describe('UnreferencedFeedbackComponent', () => { let comp: UnreferencedFeedbackComponent; @@ -16,8 +16,7 @@ describe('UnreferencedFeedbackComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [ArtemisTestModule], - declarations: [UnreferencedFeedbackComponent, MockPipe(ArtemisTranslatePipe), MockComponent(UnreferencedFeedbackDetailComponent)], - providers: [], + declarations: [UnreferencedFeedbackComponent, UnreferencedFeedbackDetailStubComponent, MockPipe(ArtemisTranslatePipe)], }) .compileComponents() .then(() => { @@ -89,12 +88,13 @@ describe('UnreferencedFeedbackComponent', () => { }); it('should add unreferenced feedback on dropping assessment instruction', () => { - const instruction: GradingInstruction = { id: 1, credits: 2, feedback: 'test', gradingScale: 'good', instructionDescription: 'description of instruction', usageCount: 0 }; + const instruction = { id: 1, credits: 2, feedback: 'test', gradingScale: 'good', instructionDescription: 'description of instruction', usageCount: 0 }; comp.unreferencedFeedback = []; - jest.spyOn(sgiService, 'updateFeedbackWithStructuredGradingInstructionEvent').mockImplementation(() => { - comp.unreferencedFeedback[0].gradingInstruction = instruction; - comp.unreferencedFeedback[0].credits = instruction.credits; + jest.spyOn(sgiService, 'updateFeedbackWithStructuredGradingInstructionEvent').mockImplementation((feedback) => { + feedback.gradingInstruction = instruction; + feedback.credits = instruction.credits; }); + // Call spy function with empty event comp.createAssessmentOnDrop(new Event('')); expect(comp.unreferencedFeedback).toHaveLength(1); @@ -107,7 +107,33 @@ describe('UnreferencedFeedbackComponent', () => { comp.feedbackSuggestions = [suggestion]; comp.acceptSuggestion(suggestion); expect(comp.feedbackSuggestions).toBeEmpty(); - expect(comp.unreferencedFeedback).toEqual([{ text: 'FeedbackSuggestion:accepted:', detailText: 'test', type: FeedbackType.MANUAL_UNREFERENCED }]); + expect(comp.unreferencedFeedback).toEqual([ + { + text: 'FeedbackSuggestion:accepted:', + detailText: 'test', + type: FeedbackType.MANUAL_UNREFERENCED, + }, + ]); + }); + + it('should only replace feedback on drop, not add another one', () => { + jest.spyOn(sgiService, 'updateFeedbackWithStructuredGradingInstructionEvent').mockImplementation(); + comp.createAssessmentOnDrop(new Event('')); + fixture.detectChanges(); + + const unreferencedFeedbackDetailDebugElement = fixture.debugElement.query(By.css('jhi-unreferenced-feedback-detail')); + const unreferencedFeedbackDetailComp: UnreferencedFeedbackDetailStubComponent = unreferencedFeedbackDetailDebugElement.componentInstance; + + const createAssessmentOnDropStub: jest.SpyInstance = jest.spyOn(comp, 'createAssessmentOnDrop'); + const updateFeedbackOnDropStub: jest.SpyInstance = jest.spyOn(unreferencedFeedbackDetailComp, 'updateFeedbackOnDrop'); + + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }); + unreferencedFeedbackDetailDebugElement.nativeElement.querySelector('div').dispatchEvent(dropEvent); + fixture.detectChanges(); + + expect(updateFeedbackOnDropStub).toHaveBeenCalledOnce(); + // do not propagate the event to the parent component + expect(createAssessmentOnDropStub).not.toHaveBeenCalled(); }); it('should remove discarded suggestions', () => { diff --git a/src/test/javascript/spec/component/shared/user-settings/user-settings.directive.spec.ts b/src/test/javascript/spec/component/shared/user-settings/user-settings.directive.spec.ts index df1c00916686..01cb9563222d 100644 --- a/src/test/javascript/spec/component/shared/user-settings/user-settings.directive.spec.ts +++ b/src/test/javascript/spec/component/shared/user-settings/user-settings.directive.spec.ts @@ -1,7 +1,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AccountService } from 'app/core/auth/account.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { UserSettingsService } from 'app/shared/user-settings/user-settings.service'; import { SettingId, UserSettingsCategory } from 'app/shared/constants/user-settings.constants'; import { MockWebsocketService } from '../../../helpers/mocks/service/mock-websocket.service'; @@ -56,7 +56,7 @@ describe('User Settings Directive', () => { provideHttpClient(), provideHttpClientTesting(), MockProvider(ChangeDetectorRef), - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, { provide: AccountService, useClass: MockAccountService }, { provide: UserSettingsService, useClass: MockUserSettingsService }, { provide: Router, useValue: router }, diff --git a/src/test/javascript/spec/component/shared/user-settings/user-settings.service.spec.ts b/src/test/javascript/spec/component/shared/user-settings/user-settings.service.spec.ts index d52dd09a8b2c..bd8d69bf72b7 100644 --- a/src/test/javascript/spec/component/shared/user-settings/user-settings.service.spec.ts +++ b/src/test/javascript/spec/component/shared/user-settings/user-settings.service.spec.ts @@ -1,7 +1,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { AccountService } from 'app/core/auth/account.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { UserSettingsService } from 'app/shared/user-settings/user-settings.service'; import { SettingId, UserSettingsCategory } from 'app/shared/constants/user-settings.constants'; import { MockWebsocketService } from '../../../helpers/mocks/service/mock-websocket.service'; @@ -128,7 +128,7 @@ describe('User Settings Service', () => { providers: [ provideHttpClient(), provideHttpClientTesting(), - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, { provide: AccountService, useClass: MockAccountService }, ], }) diff --git a/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts b/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts index fe249338cccc..de22cddaaf15 100644 --- a/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts +++ b/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts @@ -309,6 +309,7 @@ describe('TextEditorComponent', () => { }); it('should receive submission from team', () => { + comp.participation = { id: 1, team: { id: 1 } } as StudentParticipation; comp.textExercise = { id: 1, studentParticipations: [] as StudentParticipation[], diff --git a/src/test/javascript/spec/core/websocket.service.spec.ts b/src/test/javascript/spec/core/websocket.service.spec.ts new file mode 100644 index 000000000000..58b4d6616ec1 --- /dev/null +++ b/src/test/javascript/spec/core/websocket.service.spec.ts @@ -0,0 +1,350 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { COMPRESSION_HEADER, ConnectionState, WebsocketService } from 'app/core/websocket/websocket.service'; +import { AccountService } from 'app/core/auth/account.service'; +import { MockAccountService } from '../helpers/mocks/service/mock-account.service'; +import { IrisWebsocketService } from 'app/iris/iris-websocket.service'; +import { defer, of } from 'rxjs'; +import { provideHttpClient } from '@angular/common/http'; +import { Message, Subscription as StompSubscription } from 'webstomp-client'; + +jest.mock('sockjs-client'); +jest.mock('webstomp-client', () => ({ + over: jest.fn().mockReturnValue({ + connect: jest.fn(), + subscribe: jest.fn(), + send: jest.fn(), + disconnect: jest.fn(), + connected: false, + }), +})); + +describe('WebsocketService', () => { + let irisWebsocketService: IrisWebsocketService; + let websocketService: WebsocketService; + + const sessionId = 1; + const channel = `/user/topic/iris/${sessionId}`; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [provideHttpClient(), provideHttpClientTesting(), IrisWebsocketService, WebsocketService, { provide: AccountService, useClass: MockAccountService }], + }); + irisWebsocketService = TestBed.inject(IrisWebsocketService); + websocketService = TestBed.inject(WebsocketService); + }); + + afterEach(() => { + websocketService.ngOnDestroy(); + irisWebsocketService.ngOnDestroy(); + jest.restoreAllMocks(); + }); + + const createMockMessage = (message: string) => { + return defer(() => Promise.resolve(message)); + }; + + it('should subscribe to a channel', fakeAsync(() => { + const subscribeSpy = jest.spyOn(websocketService, 'subscribe').mockReturnValue(websocketService); + const receiveSpy = jest.spyOn(websocketService, 'receive').mockReturnValue(of(null)); + + irisWebsocketService.subscribeToSession(sessionId); + + expect(subscribeSpy).toHaveBeenCalledWith(channel); + expect(receiveSpy).toHaveBeenCalledWith(channel); + expect(irisWebsocketService['subscribedChannels'].has(sessionId)).toBeTrue(); + })); + + it('should return an existing channel', fakeAsync(() => { + // Spy on the WebsocketService's subscribe and receive methods + const subscribeSpy = jest.spyOn(websocketService, 'subscribe').mockReturnValue(websocketService); + const receiveSpy = jest.spyOn(websocketService, 'receive').mockReturnValue(of(null)); + + // Call subscribeToSession for the first time + const firstObservable = irisWebsocketService.subscribeToSession(sessionId); + + // Call subscribeToSession for the second time + const secondObservable = irisWebsocketService.subscribeToSession(sessionId); + + // Check that subscribe and receive were called only once + expect(subscribeSpy).toHaveBeenCalledOnce(); + expect(receiveSpy).toHaveBeenCalledOnce(); + + // Check that the same observable was returned both times + expect(firstObservable).toStrictEqual(secondObservable); + })); + + it('should emit a message', fakeAsync(() => { + const testMessage = 'Test message'; + + // Spy on the WebsocketService's subscribe and receive methods + const subscribeSpy = jest.spyOn(websocketService, 'subscribe').mockReturnValue(websocketService); + const receiveSpy = jest.spyOn(websocketService, 'receive').mockReturnValue(defer(() => Promise.resolve(testMessage))); + + // Call subscribeToSession and subscribe to the returned observable + const observable = irisWebsocketService.subscribeToSession(sessionId); + let receivedMessage: any; + observable.subscribe((message) => { + // Store the message emitted by the observable + receivedMessage = message; + }); + tick(); + expect(receivedMessage).toEqual(testMessage); + // Check that subscribe and receive were called with the correct channel + expect(subscribeSpy).toHaveBeenCalledWith(channel); + expect(receiveSpy).toHaveBeenCalledWith(channel); + })); + + it('should emit and decode a message', fakeAsync(() => { + const testMessage = 'Test message'; + const encodedMessage = window.btoa(testMessage); + const subscribeSpy = jest.spyOn(websocketService, 'subscribe').mockReturnValue(websocketService); + const receiveSpy = jest.spyOn(websocketService, 'receive').mockReturnValue(createMockMessage(encodedMessage)); + + const observable = irisWebsocketService.subscribeToSession(sessionId); + let receivedMessage: any; + + observable.subscribe((message) => { + receivedMessage = window.atob(message); // Decode the Base64 message + }); + + tick(); + + expect(receivedMessage).toEqual(testMessage); + expect(subscribeSpy).toHaveBeenCalledWith(channel); + expect(receiveSpy).toHaveBeenCalledWith(channel); + })); + + it('should unsubscribe from a channel', fakeAsync(() => { + jest.spyOn(websocketService, 'subscribe').mockReturnValue(websocketService); + jest.spyOn(websocketService, 'receive').mockReturnValue(of(null)); + const unsubscribeSpy = jest.spyOn(websocketService, 'unsubscribe'); + + irisWebsocketService.subscribeToSession(sessionId); + expect(irisWebsocketService['subscribedChannels'].has(sessionId)).toBeTrue(); + + const result = irisWebsocketService.unsubscribeFromSession(sessionId); + + expect(unsubscribeSpy).toHaveBeenCalledWith(channel); + + // Check that the sessionId was removed from the subscribedChannels map + expect(irisWebsocketService['subscribedChannels'].has(sessionId)).toBeFalse(); + + // Check that the method returned true + expect(result).toBeTrue(); + })); + + it('should handle invalid Base64 messages gracefully', fakeAsync(() => { + const invalidBase64 = 'InvalidMessage$$'; // Not a valid Base64 string + jest.spyOn(websocketService, 'subscribe').mockReturnValue(websocketService); + jest.spyOn(websocketService, 'receive').mockReturnValue(defer(() => Promise.resolve(invalidBase64))); + + const observable = irisWebsocketService.subscribeToSession(sessionId); + let receivedMessage: any; + + observable.subscribe({ + next: (message) => { + try { + // Attempt to decode the invalid Base64 + receivedMessage = window.atob(message); + } catch (error) { + receivedMessage = null; // Handle decoding error + } + }, + }); + + tick(); + + // Ensure the message was handled gracefully + expect(receivedMessage).toBeNull(); // Expect null because decoding should fail + })); + + it('should compress and decompress correctly', () => { + // Arrange + const largePayload = { data: 'x'.repeat(2000) }; // Creates a large JSON payload + const jsonPayload = JSON.stringify(largePayload); + // @ts-ignore + const compressedAndEncodedPayload = WebsocketService.compressAndEncode(jsonPayload); + // @ts-ignore + const originalPayload = WebsocketService.decodeAndDecompress(compressedAndEncodedPayload); + expect(originalPayload).toEqual(jsonPayload); + }); + + it('should handle reconnection with backoff', fakeAsync(() => { + jest.useFakeTimers(); + const timeoutSpy = jest.spyOn(global, 'setTimeout'); + + websocketService.enableReconnect(); + websocketService['consecutiveFailedAttempts'] = 0; + + websocketService.stompFailureCallback(); + expect(websocketService['consecutiveFailedAttempts']).toBe(1); + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); + + websocketService.stompFailureCallback(); + expect(websocketService['consecutiveFailedAttempts']).toBe(2); + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); + + websocketService.stompFailureCallback(); + expect(websocketService['consecutiveFailedAttempts']).toBe(3); + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10000); + + websocketService['consecutiveFailedAttempts'] = 4; + websocketService.stompFailureCallback(); + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 20000); + + websocketService['consecutiveFailedAttempts'] = 8; + websocketService.stompFailureCallback(); + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60000); + + websocketService['consecutiveFailedAttempts'] = 12; + websocketService.stompFailureCallback(); + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 120000); + + websocketService['consecutiveFailedAttempts'] = 17; + websocketService.stompFailureCallback(); + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 300000); + + websocketService['consecutiveFailedAttempts'] = 20; + websocketService.stompFailureCallback(); + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 600000); + + jest.useRealTimers(); + })); + + it('should not reconnect when reconnect is disabled', fakeAsync(() => { + jest.useFakeTimers(); + const connectSpy = jest.spyOn(websocketService, 'connect'); + websocketService.disableReconnect(); + websocketService.stompFailureCallback(); + jest.runAllTimers(); + expect(connectSpy).not.toHaveBeenCalled(); + jest.useRealTimers(); + })); + + it('should handle sending message when disconnected', () => { + websocketService.connect(); + const sendSpy = jest.spyOn(websocketService['stompClient']!, 'send'); + websocketService['stompClient']!.connected = false; + + websocketService.send('/test', { data: 'test' }); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('should handle large messages with compression', () => { + websocketService.connect(); + websocketService['stompClient']!.connected = true; + const sendSpy = jest.spyOn(websocketService['stompClient']!, 'send'); + const largeData = { data: 'x'.repeat(2000) }; + + websocketService.send('/test', largeData); + + expect(sendSpy).toHaveBeenCalledWith('/test', expect.any(String), { 'X-Compressed': 'true' }); + }); + + it('should handle undefined channel subscription', () => { + // This test case is simply to hit the initial check in the function + const result = websocketService.subscribe(undefined!); + expect(result).toBe(websocketService); + }); + + it('should handle multiple subscriptions to same channel', fakeAsync(() => { + websocketService.connect(); + websocketService['connectionStateInternal'].next(new ConnectionState(true, true, false)); + + const channel = '/test/channel'; + websocketService.subscribe(channel); + websocketService.subscribe(channel); + + tick(); + + expect(websocketService['stompSubscriptions'].size).toBe(1); + websocketService['stompSubscriptions'] = new Map(); + })); + + it('should handle unsubscribe from non-existent channel', () => { + const channel = '/non-existent'; + expect(() => websocketService.unsubscribe(channel)).not.toThrow(); + }); + + it('should handle multiple connect calls', () => { + const connectSpy = jest.spyOn(websocketService, 'connect'); + websocketService.connect(); + websocketService.connect(); + + expect(connectSpy).toHaveBeenCalledTimes(2); + expect(websocketService['connecting']).toBeTruthy(); + }); + + it('should handle JSON parsing errors', () => { + const invalidJson = 'invalid-json'; + // @ts-ignore + const result = WebsocketService.parseJSON(invalidJson); + expect(result).toBe(invalidJson); + }); + + it('should handle incoming message with no compression', () => { + const channel = '/topic/test'; + const message: Message = { + body: JSON.stringify({ data: 'test' }), + headers: {}, + ack: jest.fn(), + nack: jest.fn(), + command: '', + }; + const subscriber = jest.fn(); + // @ts-ignore + websocketService['subscribers'].set(channel, { next: subscriber }); + + websocketService['handleIncomingMessage'](channel)(message); + + expect(subscriber).toHaveBeenCalledWith({ data: 'test' }); + }); + + it('should handle incoming message with compression', () => { + const channel = '/topic/test'; + // @ts-ignore + const messageBody = WebsocketService.compressAndEncode(JSON.stringify({ data: 'test' })); + const message: Message = { + body: messageBody, + headers: COMPRESSION_HEADER, + ack: jest.fn(), + nack: jest.fn(), + command: '', + }; + const subscriber = jest.fn(); + // @ts-ignore + websocketService['subscribers'].set(channel, { next: subscriber }); + + websocketService['handleIncomingMessage'](channel)(message); + + expect(subscriber).toHaveBeenCalledWith({ data: 'test' }); + }); + + it('should update observables when calling receive', () => { + expect(websocketService['observables'].size).toBe(0); + websocketService.receive('/test/topic'); + expect(websocketService['observables'].size).toBe(1); + websocketService.receive('/test/topic'); + expect(websocketService['observables'].size).toBe(1); + websocketService.receive('/test/topictwo'); + expect(websocketService['observables'].size).toBe(2); + }); + + it('should have default value for get session id if unsubscribed', () => { + expect(websocketService['getSessionId']()).toBe('unsubscribed'); + }); + + it('should enable and disable reconnect when functions are called', () => { + let connectSpy = jest.spyOn(websocketService, 'connect'); + websocketService.connect(); + websocketService['stompClient']!.connected = false; + expect(websocketService['shouldReconnect']).toBeFalsy(); + websocketService.enableReconnect(); + expect(websocketService['shouldReconnect']).toBeTruthy(); + expect(connectSpy).toHaveBeenCalledTimes(2); + websocketService.disableReconnect(); + expect(websocketService['shouldReconnect']).toBeFalsy(); + }); +}); diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-file.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-file.service.ts index 0e13c1e21090..24633867198e 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-file.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-file.service.ts @@ -16,6 +16,10 @@ export class MockFileService { return of(); }; + createAttachmentFileUrl(downloadUrl: string, downloadName: string, encodeName: boolean) { + return 'attachments/' + downloadName.replace(' ', '-') + '.pdf'; + } + replaceLectureAttachmentPrefixAndUnderscores = (link: string) => link; replaceAttachmentPrefixAndUnderscores = (link: string) => link; } diff --git a/src/test/javascript/spec/helpers/stubs/loading-indicator-container-stub.component.ts b/src/test/javascript/spec/helpers/stubs/loading-indicator-container-stub.component.ts index 0f5c48cc51a3..32de4d93157f 100644 --- a/src/test/javascript/spec/helpers/stubs/loading-indicator-container-stub.component.ts +++ b/src/test/javascript/spec/helpers/stubs/loading-indicator-container-stub.component.ts @@ -1,6 +1,4 @@ -import { Component } from '@angular/core'; - -import { Input } from '@angular/core'; +import { Component, Input } from '@angular/core'; @Component({ selector: 'jhi-loading-indicator-container', template: '' }) export class LoadingIndicatorContainerStubComponent { diff --git a/src/test/javascript/spec/helpers/stubs/unreferenced-feedback-detail-stub.component.ts b/src/test/javascript/spec/helpers/stubs/unreferenced-feedback-detail-stub.component.ts new file mode 100644 index 000000000000..f3955acd79d1 --- /dev/null +++ b/src/test/javascript/spec/helpers/stubs/unreferenced-feedback-detail-stub.component.ts @@ -0,0 +1,25 @@ +import { Component, EventEmitter, Input, InputSignal, Output, input } from '@angular/core'; +import { Feedback } from '../../../../../main/webapp/app/entities/feedback.model'; + +@Component({ + selector: 'jhi-unreferenced-feedback-detail', + template: '
    ', +}) +export class UnreferencedFeedbackDetailStubComponent { + @Input() public feedback: Feedback; + resultId: InputSignal = input.required(); + @Input() isSuggestion: boolean; + @Input() public readOnly: boolean; + @Input() highlightDifferences: boolean; + @Input() useDefaultFeedbackSuggestionBadgeText: boolean; + + @Output() public onFeedbackChange = new EventEmitter(); + @Output() public onFeedbackDelete = new EventEmitter(); + @Output() onAcceptSuggestion = new EventEmitter(); + @Output() onDiscardSuggestion = new EventEmitter(); + + updateFeedbackOnDrop(event: Event) { + // stop the event-bubbling, just like in the actual component + event.stopPropagation(); + } +} diff --git a/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts b/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts index f90fad7ce190..95033800a99b 100644 --- a/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts +++ b/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts @@ -24,7 +24,7 @@ import { ProgrammingSubmissionService, ProgrammingSubmissionState, ProgrammingSu import { MockProgrammingSubmissionService } from '../../helpers/mocks/service/mock-programming-submission.service'; import { GuidedTourService } from 'app/guided-tour/guided-tour.service'; import { GuidedTourMapping } from 'app/guided-tour/guided-tour-setting.model'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../helpers/mocks/service/mock-websocket.service'; import { Participation } from 'app/entities/participation/participation.model'; import { BuildLogEntryArray } from 'app/entities/programming/build-log.model'; @@ -86,7 +86,7 @@ describe('CodeEditorContainerIntegration', () => { CodeEditorConflictStateService, MockProvider(AlertService), { provide: ActivatedRoute, useClass: MockActivatedRouteWithSubjects }, - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, { provide: ParticipationWebsocketService, useClass: MockParticipationWebsocketService }, { provide: ProgrammingExerciseParticipationService, useClass: MockProgrammingExerciseParticipationService }, { provide: SessionStorageService, useClass: MockSyncStorage }, diff --git a/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts b/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts index 2569e345ff4c..c67a0ed52664 100644 --- a/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts +++ b/src/test/javascript/spec/integration/code-editor/code-editor-instructor.integration.spec.ts @@ -39,7 +39,7 @@ import { MockCodeEditorRepositoryFileService } from '../../helpers/mocks/service import { MockParticipationWebsocketService } from '../../helpers/mocks/service/mock-participation-websocket.service'; import { MockParticipationService } from '../../helpers/mocks/service/mock-participation.service'; import { MockProgrammingExerciseService } from '../../helpers/mocks/service/mock-programming-exercise.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../helpers/mocks/service/mock-websocket.service'; import { MockComponent, MockModule, MockPipe } from 'ng-mocks'; import { CodeEditorContainerComponent } from 'app/exercises/programming/shared/code-editor/container/code-editor-container.component'; @@ -137,7 +137,7 @@ describe('CodeEditorInstructorIntegration', () => { { provide: ParticipationService, useClass: MockParticipationService }, { provide: ProgrammingExerciseParticipationService, useClass: MockProgrammingExerciseParticipationService }, { provide: ProgrammingExerciseService, useClass: MockProgrammingExerciseService }, - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, ], }) .compileComponents() diff --git a/src/test/javascript/spec/integration/code-editor/code-editor-student.integration.spec.ts b/src/test/javascript/spec/integration/code-editor/code-editor-student.integration.spec.ts index 6b3b8441f2e7..79dac1bd740a 100644 --- a/src/test/javascript/spec/integration/code-editor/code-editor-student.integration.spec.ts +++ b/src/test/javascript/spec/integration/code-editor/code-editor-student.integration.spec.ts @@ -15,7 +15,7 @@ import { MockProgrammingExerciseParticipationService } from '../../helpers/mocks import { ProgrammingSubmissionService } from 'app/exercises/programming/participate/programming-submission.service'; import { MockProgrammingSubmissionService } from '../../helpers/mocks/service/mock-programming-submission.service'; import { getElement } from '../../helpers/utils/general.utils'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../helpers/mocks/service/mock-websocket.service'; import { Participation } from 'app/entities/participation/participation.model'; import { ResultService } from 'app/exercises/shared/result/result.service'; @@ -112,7 +112,7 @@ describe('CodeEditorStudentIntegration', () => { JhiLanguageHelper, { provide: AccountService, useClass: MockAccountService }, { provide: ActivatedRoute, useClass: MockActivatedRouteWithSubjects }, - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, { provide: ParticipationWebsocketService, useClass: MockParticipationWebsocketService }, { provide: ProgrammingExerciseParticipationService, useClass: MockProgrammingExerciseParticipationService }, { provide: SessionStorageService, useClass: MockSyncStorage }, diff --git a/src/test/javascript/spec/service/account.service.spec.ts b/src/test/javascript/spec/service/account.service.spec.ts index b9b36bd7d4b8..f5ddf6b2c5ce 100644 --- a/src/test/javascript/spec/service/account.service.spec.ts +++ b/src/test/javascript/spec/service/account.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { of } from 'rxjs'; import { MockService } from 'ng-mocks'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { FeatureToggleService } from 'app/shared/feature-toggle/feature-toggle.service'; import { MockHttpService } from '../helpers/mocks/service/mock-http.service'; import { User } from 'app/core/user/user.model'; @@ -46,7 +46,7 @@ describe('AccountService', () => { providers: [ { provide: TranslateService, useClass: MockTranslateService }, { provide: SessionStorageService, useClass: MockSyncStorage }, - { provide: JhiWebsocketService, useValue: MockService(JhiWebsocketService) }, + { provide: WebsocketService, useValue: MockService(WebsocketService) }, { provide: FeatureToggleService, useValue: MockService(FeatureToggleService) }, provideHttpClient(), provideHttpClientTesting(), diff --git a/src/test/javascript/spec/service/exam-participation-live-events.service.spec.ts b/src/test/javascript/spec/service/exam-participation-live-events.service.spec.ts index 07b49299c4ec..fbca10610483 100644 --- a/src/test/javascript/spec/service/exam-participation-live-events.service.spec.ts +++ b/src/test/javascript/spec/service/exam-participation-live-events.service.spec.ts @@ -1,7 +1,7 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { Subject, firstValueFrom } from 'rxjs'; -import { ConnectionState, JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { ConnectionState, WebsocketService } from 'app/core/websocket/websocket.service'; import { ExamParticipationService } from 'app/exam/participate/exam-participation.service'; import { ExamLiveEvent, ExamLiveEventType, ExamParticipationLiveEventsService } from 'app/exam/participate/exam-participation-live-events.service'; import { LocalStorageService } from 'ngx-webstorage'; @@ -12,7 +12,7 @@ import { provideHttpClient } from '@angular/common/http'; describe('ExamParticipationLiveEventsService', () => { let service: ExamParticipationLiveEventsService; let httpMock: HttpTestingController; - let mockWebsocketService: JhiWebsocketService; + let mockWebsocketService: WebsocketService; let mockExamParticipationService: ExamParticipationService; let mockLocalStorageService: LocalStorageService; let websocketConnectionStateSubject: Subject; @@ -32,14 +32,14 @@ describe('ExamParticipationLiveEventsService', () => { const tmpMockWebsocketService = new MockWebsocketService(); tmpMockWebsocketService.state = websocketConnectionStateSubject.asObservable(); - mockWebsocketService = tmpMockWebsocketService as unknown as JhiWebsocketService; + mockWebsocketService = tmpMockWebsocketService as unknown as WebsocketService; TestBed.configureTestingModule({ imports: [], providers: [ provideHttpClient(), provideHttpClientTesting(), - { provide: JhiWebsocketService, useValue: mockWebsocketService }, + { provide: WebsocketService, useValue: mockWebsocketService }, { provide: ExamParticipationService, useValue: mockExamParticipationService }, { provide: LocalStorageService, useValue: mockLocalStorageService }, ], @@ -47,7 +47,7 @@ describe('ExamParticipationLiveEventsService', () => { service = TestBed.inject(ExamParticipationLiveEventsService); httpMock = TestBed.inject(HttpTestingController); - mockWebsocketService = TestBed.inject(JhiWebsocketService); + mockWebsocketService = TestBed.inject(WebsocketService); service['studentExamId'] = 1; service['examId'] = 1; diff --git a/src/test/javascript/spec/service/login.service.spec.ts b/src/test/javascript/spec/service/login.service.spec.ts index 3876da12645a..f9e0e02acf6a 100644 --- a/src/test/javascript/spec/service/login.service.spec.ts +++ b/src/test/javascript/spec/service/login.service.spec.ts @@ -3,7 +3,7 @@ import { MockRouter } from '../helpers/mocks/mock-router'; import { MockAccountService } from '../helpers/mocks/service/mock-account.service'; import { MockAuthServerProviderService } from '../helpers/mocks/service/mock-auth-server-provider.service'; import { AccountService } from 'app/core/auth/account.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { LoginService } from 'app/core/login/login.service'; import { AuthServerProvider } from 'app/core/auth/auth-jwt.service'; import { TestBed } from '@angular/core/testing'; @@ -27,7 +27,7 @@ describe('LoginService', () => { TestBed.configureTestingModule({ providers: [ { provide: AccountService, useClass: MockAccountService }, - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, { provide: AuthServerProvider, useClass: MockAuthServerProviderService }, { provide: Router, useClass: MockRouter }, MockProvider(AlertService), diff --git a/src/test/javascript/spec/service/metis/metis.service.spec.ts b/src/test/javascript/spec/service/metis/metis.service.spec.ts index 021abff70aca..221bb567a92b 100644 --- a/src/test/javascript/spec/service/metis/metis.service.spec.ts +++ b/src/test/javascript/spec/service/metis/metis.service.spec.ts @@ -21,7 +21,7 @@ import { MockRouter } from '../../helpers/mocks/mock-router'; import { MockLocalStorageService } from '../../helpers/mocks/service/mock-local-storage.service'; import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; import { MockProvider } from 'ng-mocks'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MetisPostDTO } from 'app/entities/metis/metis-post-dto.model'; import { Subject, of } from 'rxjs'; import { @@ -57,7 +57,7 @@ describe('Metis Service', () => { let metisServiceCreateWebsocketSubscriptionSpy: jest.SpyInstance; let websocketServiceSubscribeSpy: jest.SpyInstance; let websocketServiceReceiveStub: jest.SpyInstance; - let websocketService: JhiWebsocketService; + let websocketService: WebsocketService; let reactionService: ReactionService; let postService: PostService; let answerPostService: AnswerPostService; @@ -89,7 +89,7 @@ describe('Metis Service', () => { ], }); metisService = TestBed.inject(MetisService); - websocketService = TestBed.inject(JhiWebsocketService); + websocketService = TestBed.inject(WebsocketService); reactionService = TestBed.inject(ReactionService); postService = TestBed.inject(PostService); answerPostService = TestBed.inject(AnswerPostService); diff --git a/src/test/javascript/spec/service/notification.service.spec.ts b/src/test/javascript/spec/service/notification.service.spec.ts index 2814844440db..896a6c7bb29b 100644 --- a/src/test/javascript/spec/service/notification.service.spec.ts +++ b/src/test/javascript/spec/service/notification.service.spec.ts @@ -20,7 +20,7 @@ import { CourseManagementService } from 'app/course/manage/course-management.ser import { BehaviorSubject, Subject } from 'rxjs'; import { AccountService } from 'app/core/auth/account.service'; import { MockAccountService } from '../helpers/mocks/service/mock-account.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../helpers/mocks/service/mock-websocket.service'; import { Course } from 'app/entities/course.model'; import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; @@ -50,7 +50,7 @@ describe('Notification Service', () => { let artemisTranslatePipe: ArtemisTranslatePipe; let accountService: MockAccountService; - let websocketService: JhiWebsocketService; + let websocketService: WebsocketService; let wsSubscribeStub: jest.SpyInstance; let wsUnsubscribeStub: jest.SpyInstance; let wsReceiveNotificationStub: jest.SpyInstance; @@ -171,7 +171,7 @@ describe('Notification Service', () => { { provide: AccountService, useClass: MockAccountService }, { provide: ArtemisTranslatePipe, useClass: ArtemisTranslatePipe }, { provide: ChangeDetectorRef, useValue: {} }, - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, { provide: MetisService, useClass: MockMetisService }, { provide: ActivatedRoute, @@ -191,7 +191,7 @@ describe('Notification Service', () => { httpMock = TestBed.inject(HttpTestingController); router = TestBed.inject(Router) as any; - websocketService = TestBed.inject(JhiWebsocketService); + websocketService = TestBed.inject(WebsocketService); artemisTranslatePipe = TestBed.inject(ArtemisTranslatePipe); accountService = TestBed.inject(AccountService) as any; wsSubscribeStub = jest.spyOn(websocketService, 'subscribe'); diff --git a/src/test/javascript/spec/service/participation-websocket.service.spec.ts b/src/test/javascript/spec/service/participation-websocket.service.spec.ts index b4dfd749c5b3..b2bc6a181a37 100644 --- a/src/test/javascript/spec/service/participation-websocket.service.spec.ts +++ b/src/test/javascript/spec/service/participation-websocket.service.spec.ts @@ -3,13 +3,13 @@ import { BehaviorSubject, Subject } from 'rxjs'; import { ParticipationWebsocketService } from 'app/overview/participation-websocket.service'; import { Participation } from 'app/entities/participation/participation.model'; import { Result } from 'app/entities/result.model'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { ParticipationService } from 'app/exercises/shared/participation/participation.service'; import { MockWebsocketService } from '../helpers/mocks/service/mock-websocket.service'; import { MockParticipationService } from '../helpers/mocks/service/mock-participation.service'; describe('ParticipationWebsocketService', () => { - let websocketService: JhiWebsocketService; + let websocketService: WebsocketService; let receiveParticipationSubject: Subject; let receiveParticipation2Subject: Subject; let receiveResultForParticipationSubject: Subject; @@ -42,14 +42,14 @@ describe('ParticipationWebsocketService', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, { provide: ParticipationService, useClass: MockParticipationService }, ], }) .compileComponents() .then(() => { participationWebsocketService = TestBed.inject(ParticipationWebsocketService); - websocketService = TestBed.inject(JhiWebsocketService); + websocketService = TestBed.inject(WebsocketService); subscribeSpy = jest.spyOn(websocketService, 'subscribe'); unsubscribeSpy = jest.spyOn(websocketService, 'unsubscribe'); diff --git a/src/test/javascript/spec/service/programming-exercise-grading.service.spec.ts b/src/test/javascript/spec/service/programming-exercise-grading.service.spec.ts index 6396a81e6dff..8fccddb80867 100644 --- a/src/test/javascript/spec/service/programming-exercise-grading.service.spec.ts +++ b/src/test/javascript/spec/service/programming-exercise-grading.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { Subject, of } from 'rxjs'; import { tap } from 'rxjs/operators'; import { MockWebsocketService } from '../helpers/mocks/service/mock-websocket.service'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { ProgrammingExerciseGradingService } from 'app/exercises/programming/manage/services/programming-exercise-grading.service'; import { MockHttpService } from '../helpers/mocks/service/mock-http.service'; import { ProgrammingExerciseTestCase } from 'app/entities/programming/programming-exercise-test-case.model'; @@ -10,7 +10,7 @@ import { Result } from 'app/entities/result.model'; import { HttpClient } from '@angular/common/http'; describe('ProgrammingExerciseGradingService', () => { - let websocketService: JhiWebsocketService; + let websocketService: WebsocketService; let httpService: HttpClient; let exercise1TestCaseSubject: Subject; let exercise2TestCaseSubject: Subject; @@ -40,12 +40,12 @@ describe('ProgrammingExerciseGradingService', () => { TestBed.configureTestingModule({ providers: [ { provide: HttpClient, useClass: MockHttpService }, - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, ], }) .compileComponents() .then(() => { - websocketService = TestBed.inject(JhiWebsocketService); + websocketService = TestBed.inject(WebsocketService); httpService = TestBed.inject(HttpClient); gradingService = TestBed.inject(ProgrammingExerciseGradingService); diff --git a/src/test/javascript/spec/service/programming-submission.service.spec.ts b/src/test/javascript/spec/service/programming-submission.service.spec.ts index 2cec23b11bbd..8435b9a3c9ff 100644 --- a/src/test/javascript/spec/service/programming-submission.service.spec.ts +++ b/src/test/javascript/spec/service/programming-submission.service.spec.ts @@ -19,14 +19,14 @@ import { ProgrammingExerciseParticipationService } from 'app/exercises/programmi import { MockProgrammingExerciseParticipationService } from '../helpers/mocks/service/mock-programming-exercise-participation.service'; import { HttpClient, provideHttpClient } from '@angular/common/http'; import { TestBed, discardPeriodicTasks, fakeAsync, tick } from '@angular/core/testing'; -import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; +import { WebsocketService } from 'app/core/websocket/websocket.service'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { ProfileService } from '../../../../main/webapp/app/shared/layouts/profiles/profile.service'; import { MockProfileService } from '../helpers/mocks/service/mock-profile.service'; import { SubmissionProcessingDTO } from '../../../../main/webapp/app/entities/programming/submission-processing-dto'; describe('ProgrammingSubmissionService', () => { - let websocketService: JhiWebsocketService; + let websocketService: WebsocketService; let httpService: HttpClient; let participationWebsocketService: ParticipationWebsocketService; let participationService: ProgrammingExerciseParticipationService; @@ -88,7 +88,7 @@ describe('ProgrammingSubmissionService', () => { providers: [ provideHttpClient(), provideHttpClientTesting(), - { provide: JhiWebsocketService, useClass: MockWebsocketService }, + { provide: WebsocketService, useClass: MockWebsocketService }, { provide: ParticipationWebsocketService, useClass: MockParticipationWebsocketService }, { provide: ProgrammingExerciseParticipationService, useClass: MockProgrammingExerciseParticipationService }, { provide: ProfileService, useClass: MockProfileService }, @@ -97,7 +97,7 @@ describe('ProgrammingSubmissionService', () => { .compileComponents() .then(() => { submissionService = TestBed.inject(ProgrammingSubmissionService); - websocketService = TestBed.inject(JhiWebsocketService); + websocketService = TestBed.inject(WebsocketService); httpService = TestBed.inject(HttpClient); participationWebsocketService = TestBed.inject(ParticipationWebsocketService); participationService = TestBed.inject(ProgrammingExerciseParticipationService); diff --git a/src/test/playwright/e2e/course/CourseManagement.spec.ts b/src/test/playwright/e2e/course/CourseManagement.spec.ts index 30241c2ad2a2..fe2b5e1bf937 100644 --- a/src/test/playwright/e2e/course/CourseManagement.spec.ts +++ b/src/test/playwright/e2e/course/CourseManagement.spec.ts @@ -1,5 +1,5 @@ import { test } from '../../support/fixtures'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Course } from 'app/entities/course.model'; import { admin, studentOne } from '../../support/users'; import { base64StringToBlob, convertBooleanToCheckIconClass, dayjsToString, generateUUID, trimDate } from '../../support/utils'; diff --git a/src/test/playwright/e2e/course/CourseMessages.spec.ts b/src/test/playwright/e2e/course/CourseMessages.spec.ts index d251556937ce..d606bbd43ca8 100644 --- a/src/test/playwright/e2e/course/CourseMessages.spec.ts +++ b/src/test/playwright/e2e/course/CourseMessages.spec.ts @@ -75,6 +75,19 @@ test.describe('Course messages', { tag: '@fast' }, () => { await expect(courseMessages.getName()).toContainText(name); }); + test('Instructor should be able to create a public course-wide unrestricted channel', async ({ login, courseMessages }) => { + await login(instructor, `/courses/${course.id}/communication`); + const name = 'public-cw-unrstct-ch'; + await courseMessages.createChannelButton(); + await courseMessages.setName(name); + await courseMessages.setDescription('A public unrestricted channel'); + await courseMessages.setPublic(); + await courseMessages.setUnrestrictedChannel(); + await courseMessages.setCourseWideChannel(); + await courseMessages.createChannel(false, true); + await expect(courseMessages.getName()).toContainText(name); + }); + test('Instructor should be able to create a private unrestricted channel', async ({ login, courseMessages }) => { await login(instructor, `/courses/${course.id}/communication`); const name = 'private-unrstct-ch'; diff --git a/src/test/playwright/e2e/exam/ExamAssessment.spec.ts b/src/test/playwright/e2e/exam/ExamAssessment.spec.ts index 31ebeada885f..dda83bae8047 100644 --- a/src/test/playwright/e2e/exam/ExamAssessment.spec.ts +++ b/src/test/playwright/e2e/exam/ExamAssessment.spec.ts @@ -1,4 +1,4 @@ -import dayjs, { Dayjs } from 'dayjs/esm'; +import dayjs, { Dayjs } from 'dayjs'; import { Exercise, ExerciseType } from '../../support/constants'; import { admin, instructor, studentFour, studentOne, studentThree, studentTwo, tutor, users } from '../../support/users'; import { Page, expect } from '@playwright/test'; diff --git a/src/test/playwright/e2e/exam/ExamChecklists.spec.ts b/src/test/playwright/e2e/exam/ExamChecklists.spec.ts index 3a5ba58a8615..8b1d943f863f 100644 --- a/src/test/playwright/e2e/exam/ExamChecklists.spec.ts +++ b/src/test/playwright/e2e/exam/ExamChecklists.spec.ts @@ -3,7 +3,7 @@ import { admin, instructor, studentOne } from '../../support/users'; import { Course } from 'app/entities/course.model'; import { Exam } from 'app/entities/exam/exam.model'; import { generateUUID, prepareExam, startAssessing } from '../../support/utils'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { ExamChecklistItem } from '../../support/pageobjects/exam/ExamDetailsPage'; import { ExerciseType } from '../../support/constants'; import textExerciseTemplate from '../../fixtures/exercise/text/template.json'; diff --git a/src/test/playwright/e2e/exam/ExamCreationDeletion.spec.ts b/src/test/playwright/e2e/exam/ExamCreationDeletion.spec.ts index cb123348834f..fb0e69ef5c92 100644 --- a/src/test/playwright/e2e/exam/ExamCreationDeletion.spec.ts +++ b/src/test/playwright/e2e/exam/ExamCreationDeletion.spec.ts @@ -2,7 +2,7 @@ import { test } from '../../support/fixtures'; import { admin } from '../../support/users'; import { Course } from 'app/entities/course.model'; import { dayjsToString, generateUUID, trimDate } from '../../support/utils'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { expect } from '@playwright/test'; import { Exam } from 'app/entities/exam/exam.model'; diff --git a/src/test/playwright/e2e/exam/ExamDateVerification.spec.ts b/src/test/playwright/e2e/exam/ExamDateVerification.spec.ts index d666af6399b9..f6cb7d547210 100644 --- a/src/test/playwright/e2e/exam/ExamDateVerification.spec.ts +++ b/src/test/playwright/e2e/exam/ExamDateVerification.spec.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Course } from 'app/entities/course.model'; import { test } from '../../support/fixtures'; import { admin, studentOne } from '../../support/users'; diff --git a/src/test/playwright/e2e/exam/ExamParticipation.spec.ts b/src/test/playwright/e2e/exam/ExamParticipation.spec.ts index c08aaba5df2d..ca86e188d575 100644 --- a/src/test/playwright/e2e/exam/ExamParticipation.spec.ts +++ b/src/test/playwright/e2e/exam/ExamParticipation.spec.ts @@ -4,7 +4,7 @@ import { Exercise, ExerciseType } from '../../support/constants'; import { admin, instructor, studentFour, studentOne, studentThree, studentTwo, tutor, users } from '../../support/users'; import { generateUUID } from '../../support/utils'; import javaAllSuccessfulSubmission from '../../fixtures/exercise/programming/java/all_successful/submission.json'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Exam } from 'app/entities/exam/exam.model'; import { expect } from '@playwright/test'; import { ExamStartEndPage } from '../../support/pageobjects/exam/ExamStartEndPage'; diff --git a/src/test/playwright/e2e/exam/ExamResults.spec.ts b/src/test/playwright/e2e/exam/ExamResults.spec.ts index 66651c492bc4..4b57dfc2e196 100644 --- a/src/test/playwright/e2e/exam/ExamResults.spec.ts +++ b/src/test/playwright/e2e/exam/ExamResults.spec.ts @@ -3,7 +3,7 @@ import { Exam } from 'app/entities/exam/exam.model'; import { Commands } from '../../support/commands'; import { admin, instructor, studentOne, tutor } from '../../support/users'; import { Course } from 'app/entities/course.model'; -import dayjs, { Dayjs } from 'dayjs/esm'; +import dayjs, { Dayjs } from 'dayjs'; import { generateUUID } from '../../support/utils'; import { Exercise, ExerciseType } from '../../support/constants'; import { ExamManagementPage } from '../../support/pageobjects/exam/ExamManagementPage'; diff --git a/src/test/playwright/e2e/exam/ExamTestRun.spec.ts b/src/test/playwright/e2e/exam/ExamTestRun.spec.ts index 1f3af25e6a31..7b15b93ebc5f 100644 --- a/src/test/playwright/e2e/exam/ExamTestRun.spec.ts +++ b/src/test/playwright/e2e/exam/ExamTestRun.spec.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Course } from 'app/entities/course.model'; import { Exam } from 'app/entities/exam/exam.model'; diff --git a/src/test/playwright/e2e/exam/test-exam/TestExamCreationDeletion.spec.ts b/src/test/playwright/e2e/exam/test-exam/TestExamCreationDeletion.spec.ts index 76bc89aa33b3..c60d95177283 100644 --- a/src/test/playwright/e2e/exam/test-exam/TestExamCreationDeletion.spec.ts +++ b/src/test/playwright/e2e/exam/test-exam/TestExamCreationDeletion.spec.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Course } from 'app/entities/course.model'; import { Exam } from 'app/entities/exam/exam.model'; diff --git a/src/test/playwright/e2e/exam/test-exam/TestExamParticipation.spec.ts b/src/test/playwright/e2e/exam/test-exam/TestExamParticipation.spec.ts index 964be6bdfa2b..5b2824dbf8f6 100644 --- a/src/test/playwright/e2e/exam/test-exam/TestExamParticipation.spec.ts +++ b/src/test/playwright/e2e/exam/test-exam/TestExamParticipation.spec.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Course } from 'app/entities/course.model'; import { Exam } from 'app/entities/exam/exam.model'; diff --git a/src/test/playwright/e2e/exam/test-exam/TestExamStudentExams.spec.ts b/src/test/playwright/e2e/exam/test-exam/TestExamStudentExams.spec.ts index 7857aefb5393..3ce0a4ef2365 100644 --- a/src/test/playwright/e2e/exam/test-exam/TestExamStudentExams.spec.ts +++ b/src/test/playwright/e2e/exam/test-exam/TestExamStudentExams.spec.ts @@ -3,7 +3,7 @@ import { Exam } from 'app/entities/exam/exam.model'; import { UserCredentials, admin, studentOne, studentThree, studentTwo, users } from '../../../support/users'; import { generateUUID } from '../../../support/utils'; import { Exercise, ExerciseType } from '../../../support/constants'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { test } from '../../../support/fixtures'; import { expect } from '@playwright/test'; import { ExamParticipationPage } from '../../../support/pageobjects/exam/ExamParticipationPage'; diff --git a/src/test/playwright/e2e/exercise/ExerciseImport.spec.ts b/src/test/playwright/e2e/exercise/ExerciseImport.spec.ts index 057271d975ff..d54c4f4268db 100644 --- a/src/test/playwright/e2e/exercise/ExerciseImport.spec.ts +++ b/src/test/playwright/e2e/exercise/ExerciseImport.spec.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Course } from 'app/entities/course.model'; import { ModelingExercise } from 'app/entities/modeling-exercise.model'; diff --git a/src/test/playwright/e2e/exercise/file-upload/FileUploadExerciseManagement.spec.ts b/src/test/playwright/e2e/exercise/file-upload/FileUploadExerciseManagement.spec.ts index 39eea86dc8bb..d6d5d318b510 100644 --- a/src/test/playwright/e2e/exercise/file-upload/FileUploadExerciseManagement.spec.ts +++ b/src/test/playwright/e2e/exercise/file-upload/FileUploadExerciseManagement.spec.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Course } from 'app/entities/course.model'; import { FileUploadExercise } from 'app/entities/file-upload-exercise.model'; diff --git a/src/test/playwright/e2e/exercise/modeling/ModelingExerciseAssessment.spec.ts b/src/test/playwright/e2e/exercise/modeling/ModelingExerciseAssessment.spec.ts index 947e205eb871..47569e73a3b4 100644 --- a/src/test/playwright/e2e/exercise/modeling/ModelingExerciseAssessment.spec.ts +++ b/src/test/playwright/e2e/exercise/modeling/ModelingExerciseAssessment.spec.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Course } from 'app/entities/course.model'; import { ModelingExercise } from 'app/entities/modeling-exercise.model'; diff --git a/src/test/playwright/e2e/exercise/modeling/ModelingExerciseManagement.spec.ts b/src/test/playwright/e2e/exercise/modeling/ModelingExerciseManagement.spec.ts index d5348fd27662..dec709eadd8c 100644 --- a/src/test/playwright/e2e/exercise/modeling/ModelingExerciseManagement.spec.ts +++ b/src/test/playwright/e2e/exercise/modeling/ModelingExerciseManagement.spec.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { MODELING_EDITOR_CANVAS } from '../../../support/constants'; import { Course } from 'app/entities/course.model'; diff --git a/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseAssessment.spec.ts b/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseAssessment.spec.ts index 2c9f7d7284cd..fc1282c3fc36 100644 --- a/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseAssessment.spec.ts +++ b/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseAssessment.spec.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Course } from 'app/entities/course.model'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; diff --git a/src/test/playwright/e2e/exercise/quiz-exercise/QuizExerciseParticipation.spec.ts b/src/test/playwright/e2e/exercise/quiz-exercise/QuizExerciseParticipation.spec.ts index 62c8af185dc2..8a0a7f735062 100644 --- a/src/test/playwright/e2e/exercise/quiz-exercise/QuizExerciseParticipation.spec.ts +++ b/src/test/playwright/e2e/exercise/quiz-exercise/QuizExerciseParticipation.spec.ts @@ -5,7 +5,7 @@ import shortAnswerQuizTemplate from '../../../fixtures/exercise/quiz/short_answe import { admin, instructor, studentOne } from '../../../support/users'; import { test } from '../../../support/fixtures'; import { expect } from '@playwright/test'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { QuizMode } from '../../../support/constants'; test.describe('Quiz Exercise Participation', { tag: '@fast' }, () => { diff --git a/src/test/playwright/e2e/exercise/text/TextExerciseAssessment.spec.ts b/src/test/playwright/e2e/exercise/text/TextExerciseAssessment.spec.ts index 2b8e37f954ac..ef80849596fa 100644 --- a/src/test/playwright/e2e/exercise/text/TextExerciseAssessment.spec.ts +++ b/src/test/playwright/e2e/exercise/text/TextExerciseAssessment.spec.ts @@ -1,6 +1,6 @@ import { Course } from 'app/entities/course.model'; import { TextExercise } from 'app/entities/text/text-exercise.model'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { admin, instructor, studentOne, tutor } from '../../../support/users'; import { test } from '../../../support/fixtures'; diff --git a/src/test/playwright/e2e/exercise/text/TextExerciseManagement.spec.ts b/src/test/playwright/e2e/exercise/text/TextExerciseManagement.spec.ts index 7be23ebc78ac..c8a4b0a26dba 100644 --- a/src/test/playwright/e2e/exercise/text/TextExerciseManagement.spec.ts +++ b/src/test/playwright/e2e/exercise/text/TextExerciseManagement.spec.ts @@ -3,7 +3,7 @@ import { TextExercise } from 'app/entities/text/text-exercise.model'; import { test } from '../../../support/fixtures'; import { admin } from '../../../support/users'; import { generateUUID } from '../../../support/utils'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { expect } from '@playwright/test'; import { ExampleSubmission } from 'app/entities/example-submission.model'; import { TextSubmission } from 'app/entities/text/text-submission.model'; diff --git a/src/test/playwright/e2e/lecture/LectureManagement.spec.ts b/src/test/playwright/e2e/lecture/LectureManagement.spec.ts index 541d256590f6..c229c40f701d 100644 --- a/src/test/playwright/e2e/lecture/LectureManagement.spec.ts +++ b/src/test/playwright/e2e/lecture/LectureManagement.spec.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Course } from 'app/entities/course.model'; import { Lecture } from 'app/entities/lecture.model'; diff --git a/src/test/playwright/init/importUsers.spec.ts b/src/test/playwright/init/importUsers.spec.ts index 8ccf886c770b..ea2c17000f32 100644 --- a/src/test/playwright/init/importUsers.spec.ts +++ b/src/test/playwright/init/importUsers.spec.ts @@ -1,5 +1,5 @@ import { test } from '../support/fixtures'; -import { admin, instructor, studentFour, studentOne, studentThree, studentTwo, tutor } from '../support/users'; +import { admin, instructor, studentFour, studentOne, studentThree, studentTwo, tutor, UserRole } from '../support/users'; import { USER_ID, USER_ROLE, users } from '../support/users'; test.describe('Setup users', async () => { @@ -7,10 +7,14 @@ test.describe('Setup users', async () => { test.beforeEach('Creates all required users', async ({ login, userManagementAPIRequests }) => { await login(admin); for (const userKey in USER_ID) { - const user = users.getUserWithId(USER_ID[userKey]); + // @ts-ignore + const userId: number = USER_ID[userKey]; + const user = users.getUserWithId(userId); const getUserResponse = await userManagementAPIRequests.getUser(user.username); if (!getUserResponse.ok()) { - await userManagementAPIRequests.createUser(user.username, user.password, USER_ROLE[userKey]); + // @ts-ignore + const userRole: UserRole = USER_ROLE[userKey]; + await userManagementAPIRequests.createUser(user.username, user.password, userRole); } } }); diff --git a/src/test/playwright/package.json b/src/test/playwright/package.json index b942f54cf3c6..6c5c4097c04b 100644 --- a/src/test/playwright/package.json +++ b/src/test/playwright/package.json @@ -23,6 +23,7 @@ "playwright:setup-local": "npx playwright install --with-deps chromium", "playwright:init": "playwright test init", "merge-reports": "junit-merge ./test-reports/results-parallel.xml ./test-reports/results-sequential.xml -o ./test-reports/results.xml", - "update": "ncu -i --format group" + "update": "ncu -i --format group", + "pretest": "tsc --incremental -p tsconfig.json" } } diff --git a/src/test/playwright/playwright.config.ts b/src/test/playwright/playwright.config.ts index b0d4124cec66..ea3f6ca0bacf 100644 --- a/src/test/playwright/playwright.config.ts +++ b/src/test/playwright/playwright.config.ts @@ -1,6 +1,9 @@ import { defineConfig, devices } from '@playwright/test'; import dotenv from 'dotenv'; import { parseNumber } from './support/utils'; +import 'app/shared/util/map.extension'; +import 'app/shared/util/string.extension'; +import 'app/shared/util/array.extension'; /** * Read environment variables from file. diff --git a/src/test/playwright/support/pageobjects/assessment/StudentAssessmentPage.ts b/src/test/playwright/support/pageobjects/assessment/StudentAssessmentPage.ts index 7ef17fff1cd5..de47af958fb5 100644 --- a/src/test/playwright/support/pageobjects/assessment/StudentAssessmentPage.ts +++ b/src/test/playwright/support/pageobjects/assessment/StudentAssessmentPage.ts @@ -6,8 +6,6 @@ import { expect } from '@playwright/test'; */ export class StudentAssessmentPage { protected readonly page: Page; - private complaintResponseSelector = '#complainResponseTextArea'; - constructor(page: Page) { this.page = page; } diff --git a/src/test/playwright/support/pageobjects/course/CourseCreationPage.ts b/src/test/playwright/support/pageobjects/course/CourseCreationPage.ts index 3df83dc22894..cc582aadd368 100644 --- a/src/test/playwright/support/pageobjects/course/CourseCreationPage.ts +++ b/src/test/playwright/support/pageobjects/course/CourseCreationPage.ts @@ -1,5 +1,5 @@ import { Page } from '@playwright/test'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { COURSE_ADMIN_BASE, COURSE_BASE } from '../../constants'; import { enterDate } from '../../utils'; diff --git a/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts b/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts index 2a07b1fbf404..c7645ce58e73 100644 --- a/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts +++ b/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts @@ -112,6 +112,13 @@ export class CourseMessagesPage { await this.page.locator('.modal-content label[for="public"]').click(); } + /** + * Marks a channel as course-wide in the modal dialog. + */ + async setCourseWideChannel() { + await this.page.locator('.modal-content label[for="isCourseWideChannel"]').click(); + } + /** * Marks a channel as an announcement channel in the modal dialog. */ diff --git a/src/test/playwright/support/pageobjects/exam/ExamCreationPage.ts b/src/test/playwright/support/pageobjects/exam/ExamCreationPage.ts index 72bb7a49443e..8fe6ad86c5d3 100644 --- a/src/test/playwright/support/pageobjects/exam/ExamCreationPage.ts +++ b/src/test/playwright/support/pageobjects/exam/ExamCreationPage.ts @@ -1,5 +1,5 @@ import { Page } from '@playwright/test'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { clearTextField, enterDate } from '../../utils'; import { COURSE_BASE } from '../../constants'; diff --git a/src/test/playwright/support/pageobjects/exam/ExamManagementPage.ts b/src/test/playwright/support/pageobjects/exam/ExamManagementPage.ts index fdd0dde5aa4e..e2e5f76c24f0 100644 --- a/src/test/playwright/support/pageobjects/exam/ExamManagementPage.ts +++ b/src/test/playwright/support/pageobjects/exam/ExamManagementPage.ts @@ -1,5 +1,5 @@ import { Page, expect } from '@playwright/test'; -import { Dayjs } from 'dayjs/esm'; +import { Dayjs } from 'dayjs'; /** * A class which encapsulates UI selectors and actions for the exam management page. diff --git a/src/test/playwright/support/pageobjects/exam/ExamParticipationActions.ts b/src/test/playwright/support/pageobjects/exam/ExamParticipationActions.ts index 1394ede58345..0fee7970a6ea 100644 --- a/src/test/playwright/support/pageobjects/exam/ExamParticipationActions.ts +++ b/src/test/playwright/support/pageobjects/exam/ExamParticipationActions.ts @@ -2,7 +2,7 @@ import { Page, expect } from '@playwright/test'; import { Fixtures } from '../../../fixtures/fixtures'; import { Commands } from '../../commands'; import { getExercise } from '../../utils'; -import { Dayjs } from 'dayjs/esm'; +import { Dayjs } from 'dayjs'; export class ExamParticipationActions { protected readonly page: Page; diff --git a/src/test/playwright/support/pageobjects/exam/ModalDialogBox.ts b/src/test/playwright/support/pageobjects/exam/ModalDialogBox.ts index dee8e78e2c9d..54de2cf3f068 100644 --- a/src/test/playwright/support/pageobjects/exam/ModalDialogBox.ts +++ b/src/test/playwright/support/pageobjects/exam/ModalDialogBox.ts @@ -1,5 +1,5 @@ import { Page, expect } from '@playwright/test'; -import { Dayjs } from 'dayjs/esm'; +import { Dayjs } from 'dayjs'; export class ModalDialogBox { private readonly page: Page; diff --git a/src/test/playwright/support/pageobjects/exercises/file-upload/FileUploadExerciseCreationPage.ts b/src/test/playwright/support/pageobjects/exercises/file-upload/FileUploadExerciseCreationPage.ts index fcbcd2005067..760eb91275dc 100644 --- a/src/test/playwright/support/pageobjects/exercises/file-upload/FileUploadExerciseCreationPage.ts +++ b/src/test/playwright/support/pageobjects/exercises/file-upload/FileUploadExerciseCreationPage.ts @@ -1,6 +1,6 @@ import { Page } from 'playwright'; import { UPLOAD_EXERCISE_BASE } from '../../../constants'; -import { Dayjs } from 'dayjs/esm'; +import { Dayjs } from 'dayjs'; import { enterDate } from '../../../utils'; export class FileUploadExerciseCreationPage { diff --git a/src/test/playwright/support/pageobjects/exercises/modeling/CreateModelingExercisePage.ts b/src/test/playwright/support/pageobjects/exercises/modeling/CreateModelingExercisePage.ts index 34b87ba5905a..c8a321895e0b 100644 --- a/src/test/playwright/support/pageobjects/exercises/modeling/CreateModelingExercisePage.ts +++ b/src/test/playwright/support/pageobjects/exercises/modeling/CreateModelingExercisePage.ts @@ -1,6 +1,6 @@ import { Page } from '@playwright/test'; import { MODELING_EXERCISE_BASE } from '../../../constants'; -import { Dayjs } from 'dayjs/esm'; +import { Dayjs } from 'dayjs'; import { enterDate } from '../../../utils'; export class CreateModelingExercisePage { diff --git a/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts b/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts index 421247ee0340..69cd9bdf3514 100644 --- a/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts +++ b/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts @@ -1,6 +1,6 @@ import { Locator, Page, expect } from '@playwright/test'; import { PROGRAMMING_EXERCISE_BASE, ProgrammingLanguage } from '../../../constants'; -import { Dayjs } from 'dayjs/esm'; +import { Dayjs } from 'dayjs'; const OWL_DATEPICKER_ARIA_LABEL_DATE_FORMAT = 'MMMM D, YYYY'; diff --git a/src/test/playwright/support/pageobjects/exercises/quiz/QuizExerciseCreationPage.ts b/src/test/playwright/support/pageobjects/exercises/quiz/QuizExerciseCreationPage.ts index 1edb93785500..2452d866eb9b 100644 --- a/src/test/playwright/support/pageobjects/exercises/quiz/QuizExerciseCreationPage.ts +++ b/src/test/playwright/support/pageobjects/exercises/quiz/QuizExerciseCreationPage.ts @@ -1,6 +1,6 @@ import { Page, expect } from '@playwright/test'; import { clearTextField, drag, enterDate } from '../../../utils'; -import { Dayjs } from 'dayjs/esm'; +import { Dayjs } from 'dayjs'; import { QUIZ_EXERCISE_BASE } from '../../../constants'; import { Fixtures } from '../../../../fixtures/fixtures'; @@ -76,9 +76,9 @@ export class QuizExerciseCreationPage { const boundingBox = await element?.boundingBox(); expect(boundingBox, { message: 'Could not get bounding box of element' }).not.toBeNull(); - await this.page.mouse.move(boundingBox.x + 800, boundingBox.y + 10); + await this.page.mouse.move(boundingBox!.x + 800, boundingBox!.y + 10); await this.page.mouse.down(); - await this.page.mouse.move(boundingBox.x + 1000, boundingBox.y + 150); + await this.page.mouse.move(boundingBox!.x + 1000, boundingBox!.y + 150); await this.page.mouse.up(); await this.createDragAndDropItem('Rick Astley'); diff --git a/src/test/playwright/support/pageobjects/exercises/text/TextExerciseCreationPage.ts b/src/test/playwright/support/pageobjects/exercises/text/TextExerciseCreationPage.ts index 10e8fdadd3c9..b2c934b7f3f6 100644 --- a/src/test/playwright/support/pageobjects/exercises/text/TextExerciseCreationPage.ts +++ b/src/test/playwright/support/pageobjects/exercises/text/TextExerciseCreationPage.ts @@ -1,5 +1,5 @@ import { Locator, Page } from '@playwright/test'; -import { Dayjs } from 'dayjs/esm'; +import { Dayjs } from 'dayjs'; import { enterDate } from '../../../utils'; import { TEXT_EXERCISE_BASE } from '../../../constants'; diff --git a/src/test/playwright/support/pageobjects/lecture/LectureCreationPage.ts b/src/test/playwright/support/pageobjects/lecture/LectureCreationPage.ts index fe35d0d5dc53..8e4fa68e7f31 100644 --- a/src/test/playwright/support/pageobjects/lecture/LectureCreationPage.ts +++ b/src/test/playwright/support/pageobjects/lecture/LectureCreationPage.ts @@ -1,5 +1,5 @@ import { Page } from 'playwright'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { BASE_API } from '../../constants'; /** diff --git a/src/test/playwright/support/pageobjects/lecture/LectureManagementPage.ts b/src/test/playwright/support/pageobjects/lecture/LectureManagementPage.ts index a80790270636..40b336fdb1de 100644 --- a/src/test/playwright/support/pageobjects/lecture/LectureManagementPage.ts +++ b/src/test/playwright/support/pageobjects/lecture/LectureManagementPage.ts @@ -1,5 +1,5 @@ import { Page } from 'playwright'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Lecture } from 'app/entities/lecture.model'; import { expect } from '@playwright/test'; import { BASE_API } from '../../constants'; diff --git a/src/test/playwright/support/requests/CommunicationAPIRequests.ts b/src/test/playwright/support/requests/CommunicationAPIRequests.ts index 30bb5f2a9a0a..71bac597ddb5 100644 --- a/src/test/playwright/support/requests/CommunicationAPIRequests.ts +++ b/src/test/playwright/support/requests/CommunicationAPIRequests.ts @@ -76,6 +76,7 @@ export class CommunicationAPIRequests { async getCourseWideChannels(courseId: number): Promise { const response = await this.page.request.get(`${COURSE_BASE}/${courseId}/conversations`); const conversations: ConversationDTO[] = await response.json(); + // @ts-ignore return conversations.filter((conv: ConversationDTO) => getAsChannelDTO(conv)?.isCourseWide === true); } diff --git a/src/test/playwright/support/requests/CourseManagementAPIRequests.ts b/src/test/playwright/support/requests/CourseManagementAPIRequests.ts index fb7089b78d92..d02469236a0d 100644 --- a/src/test/playwright/support/requests/CourseManagementAPIRequests.ts +++ b/src/test/playwright/support/requests/CourseManagementAPIRequests.ts @@ -1,5 +1,5 @@ import { Page } from '@playwright/test'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Course, CourseInformationSharingConfiguration } from 'app/entities/course.model'; import { Lecture } from 'app/entities/lecture.model'; @@ -94,6 +94,7 @@ export class CourseManagementAPIRequests { }; if (iconFileName) { + // @ts-ignore multipart['file'] = { name: iconFileName, mimeType: 'application/octet-stream', diff --git a/src/test/playwright/support/requests/ExamAPIRequests.ts b/src/test/playwright/support/requests/ExamAPIRequests.ts index 2ac213c8b761..899e1f396e93 100644 --- a/src/test/playwright/support/requests/ExamAPIRequests.ts +++ b/src/test/playwright/support/requests/ExamAPIRequests.ts @@ -1,5 +1,5 @@ import { Course } from 'app/entities/course.model'; -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Exam } from 'app/entities/exam/exam.model'; import { dayjsToString, generateUUID, titleLowercase } from '../utils'; import examTemplate from '../../fixtures/exam/template.json'; diff --git a/src/test/playwright/support/requests/ExerciseAPIRequests.ts b/src/test/playwright/support/requests/ExerciseAPIRequests.ts index 17cda812fbce..273f538abf07 100644 --- a/src/test/playwright/support/requests/ExerciseAPIRequests.ts +++ b/src/test/playwright/support/requests/ExerciseAPIRequests.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import { Page } from 'playwright-core'; import { Course } from 'app/entities/course.model'; diff --git a/src/test/playwright/support/utils.ts b/src/test/playwright/support/utils.ts index 80596ef84158..893e0d4d5adf 100644 --- a/src/test/playwright/support/utils.ts +++ b/src/test/playwright/support/utils.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs/esm'; +import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import { v4 as uuidv4 } from 'uuid'; import { Exercise, ExerciseType, ProgrammingExerciseAssessmentType, TIME_FORMAT } from './constants'; diff --git a/supporting_scripts/generate_code_cov_table/.gitignore b/supporting_scripts/code-coverage/generate_code_cov_table/.gitignore similarity index 100% rename from supporting_scripts/generate_code_cov_table/.gitignore rename to supporting_scripts/code-coverage/generate_code_cov_table/.gitignore diff --git a/supporting_scripts/generate_code_cov_table/README.md b/supporting_scripts/code-coverage/generate_code_cov_table/README.md similarity index 100% rename from supporting_scripts/generate_code_cov_table/README.md rename to supporting_scripts/code-coverage/generate_code_cov_table/README.md diff --git a/supporting_scripts/generate_code_cov_table/env.example b/supporting_scripts/code-coverage/generate_code_cov_table/env.example similarity index 100% rename from supporting_scripts/generate_code_cov_table/env.example rename to supporting_scripts/code-coverage/generate_code_cov_table/env.example diff --git a/supporting_scripts/generate_code_cov_table/generate_code_cov_table.py b/supporting_scripts/code-coverage/generate_code_cov_table/generate_code_cov_table.py similarity index 100% rename from supporting_scripts/generate_code_cov_table/generate_code_cov_table.py rename to supporting_scripts/code-coverage/generate_code_cov_table/generate_code_cov_table.py diff --git a/supporting_scripts/generate_code_cov_table/requirements.txt b/supporting_scripts/code-coverage/generate_code_cov_table/requirements.txt similarity index 100% rename from supporting_scripts/generate_code_cov_table/requirements.txt rename to supporting_scripts/code-coverage/generate_code_cov_table/requirements.txt diff --git a/supporting_scripts/code-coverage/per_module_cov_report/parse_module_coverage.py b/supporting_scripts/code-coverage/per_module_cov_report/parse_module_coverage.py new file mode 100644 index 000000000000..5047f748e136 --- /dev/null +++ b/supporting_scripts/code-coverage/per_module_cov_report/parse_module_coverage.py @@ -0,0 +1,76 @@ +import os +import xml.etree.ElementTree as ET +import argparse + +def get_report_by_module(input_directory): + results = [] + for module_folder in os.listdir(input_directory): + module_path = os.path.join(input_directory, module_folder) + + if os.path.isdir(module_path): + report_file = os.path.join(module_path, f"jacocoTestReport.xml") + + if os.path.exists(report_file): + results.append({ + "module": module_folder, + "report_file": report_file + }) + else: + print(f"No XML report file found for module: {module_folder}. Skipping...") + + return results + + +def extract_coverage(reports): + results = [] + + for report in reports: + try: + tree = ET.parse(report['report_file']) + root = tree.getroot() + + instruction_counter = root.find("./counter[@type='INSTRUCTION']") + class_counter = root.find("./counter[@type='CLASS']") + + if instruction_counter == None or class_counter == None: + continue + + instruction_covered = int(instruction_counter.get('covered', 0)) + instruction_missed = int(instruction_counter.get('missed', 0)) + total_instructions = instruction_covered + instruction_missed + instruction_coverage = (instruction_covered / total_instructions * 100) if total_instructions > 0 else 0.0 + + missed_classes = int(class_counter.get('missed', 0)) + + results.append({ + "module": report['module'], + "instruction_coverage": instruction_coverage, + "missed_classes": missed_classes + }) + except Exception as e: + print(f"Error processing {report['module']}: {e}") + + results = sorted(results, key=lambda x: x['module']) + return results + + +def write_summary_to_file(coverage_by_module, output_file): + with open(output_file, "w") as f: + f.write("## Coverage Results\n\n") + f.write("| Module Name | Instruction Coverage (%) | Missed Classes |\n") + f.write("|-------------|---------------------------|----------------|\n") + for result in coverage_by_module: + f.write(f"| {result['module']} | {result['instruction_coverage']:.2f} | {result['missed_classes']} |\n") + f.write("\n**Note**: the module with the name `test` represents the aggregated coverage of all executed test modules.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Process JaCoCo coverage reports.") + parser.add_argument("input_directory", type=str, help="Root directory containing JaCoCo coverage reports") + parser.add_argument("output_file", type=str, help="Output file to save the coverage results") + + args = parser.parse_args() + + reports = get_report_by_module(args.input_directory) + coverage_by_module = extract_coverage(reports) + write_summary_to_file(coverage_by_module, args.output_file) diff --git a/supporting_scripts/extract_number_of_server_starts.sh b/supporting_scripts/extract_number_of_server_starts.sh index 80f07e848207..a4e40eff07f2 100644 --- a/supporting_scripts/extract_number_of_server_starts.sh +++ b/supporting_scripts/extract_number_of_server_starts.sh @@ -9,8 +9,8 @@ then exit 1 fi -if [[ $numberOfStarts -ne 4 ]] +if [[ $numberOfStarts -gt 4 ]] then - echo "The number of Server Starts should be equal to 4! Please adapt this check if the change is intended or try to fix the underlying issue causing a different number of server starts!" + echo "The number of Server Starts should be lower than/equals 4! Please adapt this check if the change is intended or try to fix the underlying issue causing a different number of server starts!" exit 1 fi diff --git a/supporting_scripts/get_changed_modules.sh b/supporting_scripts/get_changed_modules.sh new file mode 100755 index 000000000000..d9188d69ee24 --- /dev/null +++ b/supporting_scripts/get_changed_modules.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Determines the changed modules following the module-directory structure for both main/test. +# Based on git-diff between the local state and an input branch name. +# Returns a comma-separated list of changed modules. +# Example: "./get_changed_modules.sh develop" + +# Check for the branch input argument. +if [ $# -eq 0 ]; then + echo "Usage: $0 " + exit 1 +fi + +BRANCH_TO_COMPARE="$1" + +MODULE_BASE_PATH="src/main/java/de/tum/cit/aet/artemis" +MODULE_BASE_TEST_PATH="src/test/java/de/tum/cit/aet/artemis" + +MODULES=$(find "$MODULE_BASE_PATH" -maxdepth 1 -mindepth 1 -type d -exec basename {} \;) +CHANGED_MODULES=() + +for MODULE in $MODULES; do + if git diff "$BRANCH_TO_COMPARE" --name-only | grep -q "^$MODULE_BASE_PATH/$MODULE/"; then + CHANGED_MODULES+=("$MODULE") + elif git diff "$BRANCH_TO_COMPARE" --name-only | grep -q "^$MODULE_BASE_TEST_PATH/$MODULE/"; then + CHANGED_MODULES+=("$MODULE") + fi +done + +IFS=, +echo "${CHANGED_MODULES[*]}"