Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String> fileEndings = List.of(".java");
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -137,6 +139,8 @@ public Mono<Void> onContentProgressedEvent(@RequestBody CloudEvent<ContentProgre
* Saves the student's code submission when received.
* Only keeps the latest submission per student per assignment.
*
* Currently, only .java files with valid content and filenames are stored.
*
* @param cloudEvent the cloud event containing the student code submission data
* @param headers request headers from Dapr
* @return Mono<Void> for reactive processing
Expand All @@ -159,14 +163,22 @@ public Mono<Void> onStudentCodeSubmittedEvent(@RequestBody CloudEvent<StudentCod
event.getCommitSha());

try {
Map<String, String> 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(),
event.getCourseId(),
event.getRepositoryUrl(),
event.getCommitSha(),
event.getCommitTimestamp(),
event.getFiles(),
filteredFiles,
event.getBranch()
);
} catch (Exception e) {
Expand All @@ -175,4 +187,38 @@ public Mono<Void> onStudentCodeSubmittedEvent(@RequestBody CloudEvent<StudentCod
}
});
}

/**
* Filters files to only include files with configured file endings and valid content.
* File endings are configured in application.properties under student.code.submission.file-endings.
*
* @param files map of file paths to file contents
* @return filtered map containing only valid files with configured endings
*/
private Map<String, String> filterFiles(Map<String, String> files) {
if (files == null) {
return new HashMap<>();
}

Map<String, String> 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);
}
}
3 changes: 3 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +20,7 @@

import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

Expand Down Expand Up @@ -46,6 +48,9 @@ class SubscriptionControllerTest {
@Mock
private StudentCodeSubmissionService studentCodeSubmissionService;

@Mock
private StudentCodeSubmissionConfig studentCodeSubmissionConfig;

@InjectMocks
private SubscriptionController subscriptionController;

Expand All @@ -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
Expand Down Expand Up @@ -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)
);
}
Expand Down Expand Up @@ -309,4 +319,230 @@ void testOnContentProgressedEvent_NullEventData_HandlesGracefully() {

verify(proactiveFeedbackService, never()).generateFeedback(any());
}

@Test
void testFileFiltering_OnlyJavaFiles_AllSaved() {
Map<String, String> 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<StudentCodeSubmittedEvent> 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<String, String> 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<StudentCodeSubmittedEvent> 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<String, String> files = new HashMap<>();
files.put("src/Main.java", "public class Main {}");
files.put(null, "some content");

StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files);
CloudEvent<StudentCodeSubmittedEvent> 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<String, String> files = new HashMap<>();
files.put("src/Main.java", "public class Main {}");
files.put("src/Empty.java", null);

StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files);
CloudEvent<StudentCodeSubmittedEvent> 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<String, String> files = new HashMap<>();
files.put("README.md", "# Documentation");
files.put("package.json", "{}");
files.put("Dockerfile", "FROM openjdk");

StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files);
CloudEvent<StudentCodeSubmittedEvent> 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<StudentCodeSubmittedEvent> 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<String, String> files = new HashMap<>();

StudentCodeSubmittedEvent event = createCodeSubmissionEvent(files);
CloudEvent<StudentCodeSubmittedEvent> 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<String, String> 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<StudentCodeSubmittedEvent> 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<String, String> 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<StudentCodeSubmittedEvent> 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<String, String> 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<StudentCodeSubmittedEvent> 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<String, String> 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();
}
}
Loading