diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/config/StudentCodeSubmissionConfig.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/config/StudentCodeSubmissionConfig.java new file mode 100644 index 0000000..bea2054 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/config/StudentCodeSubmissionConfig.java @@ -0,0 +1,24 @@ +package de.unistuttgart.iste.meitrex.tutor_service.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * Configuration properties for student code submission filtering. + */ +@Configuration +@ConfigurationProperties(prefix = "student.code.submission") +@Getter +@Setter +public class StudentCodeSubmissionConfig { + + /** + * List of file endings to save from student code submissions. + * Example: [".java", ".kt", ".py"] + */ + private List fileEndings = List.of(".java"); +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java index 117953a..39ff51b 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java @@ -5,6 +5,7 @@ import de.unistuttgart.iste.meitrex.common.event.StudentCodeSubmittedEvent; import de.unistuttgart.iste.meitrex.common.event.UserHexadPlayerTypeSetEvent; import de.unistuttgart.iste.meitrex.common.event.skilllevels.UserSkillLevelChangedEvent; +import de.unistuttgart.iste.meitrex.tutor_service.config.StudentCodeSubmissionConfig; import de.unistuttgart.iste.meitrex.tutor_service.service.ProactiveFeedbackService; import de.unistuttgart.iste.meitrex.tutor_service.service.StudentCodeSubmissionService; import de.unistuttgart.iste.meitrex.tutor_service.service.UserPlayerTypeService; @@ -34,6 +35,7 @@ public class SubscriptionController { private final UserSkillLevelService userSkillLevelService; private final ProactiveFeedbackService proactiveFeedbackService; private final StudentCodeSubmissionService studentCodeSubmissionService; + private final StudentCodeSubmissionConfig studentCodeSubmissionConfig; /** * Handles the user-hexad-player-type-set event. @@ -137,6 +139,8 @@ public Mono onContentProgressedEvent(@RequestBody CloudEvent for reactive processing @@ -159,6 +163,14 @@ public Mono onStudentCodeSubmittedEvent(@RequestBody CloudEvent filteredFiles = filterFiles(event.getFiles()); + + log.info("Filtered {} files down to {} valid files for student {} on assignment {}", + event.getFiles() != null ? event.getFiles().size() : 0, + filteredFiles.size(), + event.getStudentId(), + event.getAssignmentId()); + studentCodeSubmissionService.saveCodeSubmission( event.getStudentId(), event.getAssignmentId(), @@ -166,7 +178,7 @@ public Mono onStudentCodeSubmittedEvent(@RequestBody CloudEvent onStudentCodeSubmittedEvent(@RequestBody CloudEvent filterFiles(Map files) { + if (files == null) { + return new HashMap<>(); + } + + Map filteredFiles = new HashMap<>(); + + files.forEach((filename, content) -> { + if (filename != null && content != null && hasAllowedFileEnding(filename)) { + filteredFiles.put(filename, content); + } + }); + + return filteredFiles; + } + + /** + * Checks if a filename ends with one of the configured file endings. + * + * @param filename the filename to check + * @return true if the filename ends with an allowed ending, false otherwise + */ + private boolean hasAllowedFileEnding(String filename) { + return studentCodeSubmissionConfig.getFileEndings().stream() + .anyMatch(filename::endsWith); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d6b4e30..351a299 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -40,3 +40,6 @@ spring.jpa.open-in-view=false content_service.url=http://app-content:4001/graphql gamification_service.url=http://app-gamification:1201/graphql docproc.url=http://app-docprocai:9901/graphql/ + +# File endings to save from student code submissions (comma-separated) +student.code.submission.file-endings=.java,.kt,.py diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionControllerTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionControllerTest.java index cf2231b..0514d0e 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionControllerTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionControllerTest.java @@ -5,6 +5,7 @@ import de.unistuttgart.iste.meitrex.common.event.StudentCodeSubmittedEvent; import de.unistuttgart.iste.meitrex.common.event.UserHexadPlayerTypeSetEvent; import de.unistuttgart.iste.meitrex.common.event.skilllevels.UserSkillLevelChangedEvent; +import de.unistuttgart.iste.meitrex.tutor_service.config.StudentCodeSubmissionConfig; import de.unistuttgart.iste.meitrex.tutor_service.service.ProactiveFeedbackService; import de.unistuttgart.iste.meitrex.tutor_service.service.StudentCodeSubmissionService; import de.unistuttgart.iste.meitrex.tutor_service.service.UserPlayerTypeService; @@ -19,6 +20,7 @@ import java.time.OffsetDateTime; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -46,6 +48,9 @@ class SubscriptionControllerTest { @Mock private StudentCodeSubmissionService studentCodeSubmissionService; + @Mock + private StudentCodeSubmissionConfig studentCodeSubmissionConfig; + @InjectMocks private SubscriptionController subscriptionController; @@ -66,6 +71,9 @@ void setUp() { contentId = UUID.randomUUID(); assignmentId = UUID.randomUUID(); courseId = UUID.randomUUID(); + + lenient().when(studentCodeSubmissionConfig.getFileEndings()) + .thenReturn(List.of(".java", ".kt", ".py")); } @Test @@ -236,7 +244,9 @@ void testOnStudentCodeSubmittedEvent_SavesCodeSubmission() { eq(repositoryUrl), eq(commitSha), eq(commitTimestamp), - eq(files), + argThat(filteredFiles -> filteredFiles.size() == 2 + && filteredFiles.containsKey("src/Main.java") + && filteredFiles.containsKey("src/Helper.java")), eq(branch) ); } @@ -309,4 +319,230 @@ void testOnContentProgressedEvent_NullEventData_HandlesGracefully() { verify(proactiveFeedbackService, never()).generateFeedback(any()); } + + @Test + void testFileFiltering_OnlyJavaFiles_AllSaved() { + Map files = new HashMap<>(); + files.put("src/Main.java", "public class Main {}"); + files.put("src/Helper.java", "public class Helper {}"); + files.put("src/Utils.java", "public class Utils {}"); + + StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files); + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), + argThat(filteredFiles -> filteredFiles.size() == 3), + any() + ); + } + + @Test + void testFileFiltering_MixedFileTypes_OnlyAllowedSaved() { + Map files = new HashMap<>(); + files.put("src/Main.java", "public class Main {}"); + files.put("src/Helper.kt", "class Helper {}"); + files.put("script.py", "print('hello')"); + files.put("README.md", "# Documentation"); + files.put("package.json", "{}"); + files.put(".gitignore", "*.class"); + + StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files); + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), + argThat(filteredFiles -> + filteredFiles.size() == 3 + && filteredFiles.containsKey("src/Main.java") + && filteredFiles.containsKey("src/Helper.kt") + && filteredFiles.containsKey("script.py") + && !filteredFiles.containsKey("README.md") + && !filteredFiles.containsKey("package.json")), + any() + ); + } + + @Test + void testFileFiltering_NullFilename_Filtered() { + Map files = new HashMap<>(); + files.put("src/Main.java", "public class Main {}"); + files.put(null, "some content"); + + StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files); + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), + argThat(filteredFiles -> filteredFiles.size() == 1 && filteredFiles.containsKey("src/Main.java")), + any() + ); + } + + @Test + void testFileFiltering_NullContent_Filtered() { + Map files = new HashMap<>(); + files.put("src/Main.java", "public class Main {}"); + files.put("src/Empty.java", null); + + StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files); + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), + argThat(filteredFiles -> filteredFiles.size() == 1 && filteredFiles.containsKey("src/Main.java")), + any() + ); + } + + @Test + void testFileFiltering_NoMatchingFileEndings_EmptyMap() { + Map files = new HashMap<>(); + files.put("README.md", "# Documentation"); + files.put("package.json", "{}"); + files.put("Dockerfile", "FROM openjdk"); + + StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files); + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), + argThat(Map::isEmpty), + any() + ); + } + + @Test + void testFileFiltering_NullFilesMap_EmptyMap() { + StudentCodeSubmittedEvent event = createCodeSubmissionEvent(null); + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), + argThat(Map::isEmpty), + any() + ); + } + + @Test + void testFileFiltering_EmptyFilesMap_EmptyMap() { + Map files = new HashMap<>(); + + StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files); + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), + argThat(Map::isEmpty), + any() + ); + } + + @Test + void testFileFiltering_SingleFileEndingConfiguration_OnlyThatTypeSaved() { + // Configure to only accept .java files + when(studentCodeSubmissionConfig.getFileEndings()).thenReturn(List.of(".java")); + + Map files = new HashMap<>(); + files.put("src/Main.java", "public class Main {}"); + files.put("src/Helper.kt", "class Helper {}"); + files.put("script.py", "print('hello')"); + + StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files); + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), + argThat(filteredFiles -> + filteredFiles.size() == 1 + && filteredFiles.containsKey("src/Main.java")), + any() + ); + } + + @Test + void testFileFiltering_CaseSensitiveFileEndings() { + Map files = new HashMap<>(); + files.put("src/Main.java", "public class Main {}"); + files.put("src/Helper.JAVA", "public class Helper {}"); // uppercase extension + + StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files); + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), + argThat(filteredFiles -> + filteredFiles.size() == 1 + && filteredFiles.containsKey("src/Main.java") + && !filteredFiles.containsKey("src/Helper.JAVA")), + any() + ); + } + + @Test + void testFileFiltering_MultipleDotsInFilename() { + Map files = new HashMap<>(); + files.put("src/Main.test.java", "public class MainTest {}"); + files.put("src/config.prod.py", "config = {}"); + files.put("src/data.backup.json", "{}"); + + StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files); + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), + argThat(filteredFiles -> + filteredFiles.size() == 2 + && filteredFiles.containsKey("src/Main.test.java") + && filteredFiles.containsKey("src/config.prod.py") + && !filteredFiles.containsKey("src/data.backup.json")), + any() + ); + } + + /** + * Helper method to create a StudentCodeSubmittedEvent with the given files. + */ + private StudentCodeSubmittedEvent createCodeSubmissionEvent(Map files) { + return StudentCodeSubmittedEvent.builder() + .studentId(userId) + .assignmentId(assignmentId) + .courseId(courseId) + .repositoryUrl("https://github.com/student/repo") + .commitSha("abc123") + .commitTimestamp(OffsetDateTime.now()) + .files(files) + .branch("main") + .build(); + } }