diff --git a/src/main/java/org/wise/portal/dao/teacherpresentation/TeacherPresentationConfigDao.java b/src/main/java/org/wise/portal/dao/teacherpresentation/TeacherPresentationConfigDao.java new file mode 100644 index 000000000..863671815 --- /dev/null +++ b/src/main/java/org/wise/portal/dao/teacherpresentation/TeacherPresentationConfigDao.java @@ -0,0 +1,15 @@ +package org.wise.portal.dao.teacherpresentation; + +import org.wise.portal.dao.SimpleDao; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +public interface TeacherPresentationConfigDao + extends SimpleDao { + + /** + * @return the config for the given run, period, node and component, or null if none exists + */ + TeacherPresentationConfig getConfig(Run run, Group period, String nodeId, String componentId); +} diff --git a/src/main/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDao.java b/src/main/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDao.java new file mode 100644 index 000000000..234126d62 --- /dev/null +++ b/src/main/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDao.java @@ -0,0 +1,37 @@ +package org.wise.portal.dao.teacherpresentation.impl; + +import java.util.List; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; + +import org.springframework.stereotype.Repository; +import org.wise.portal.dao.impl.AbstractHibernateDao; +import org.wise.portal.dao.teacherpresentation.TeacherPresentationConfigDao; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +@Repository("teacherPresentationConfigDao") +public class HibernateTeacherPresentationConfigDao + extends AbstractHibernateDao + implements TeacherPresentationConfigDao { + + @Override + protected Class getDataObjectClass() { + return TeacherPresentationConfig.class; + } + + @Override + public TeacherPresentationConfig getConfig(Run run, Group period, String nodeId, + String componentId) { + CriteriaBuilder cb = getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(TeacherPresentationConfig.class); + Root root = cq.from(TeacherPresentationConfig.class); + cq.select(root).where(cb.equal(root.get("run"), run), cb.equal(root.get("period"), period), + cb.equal(root.get("nodeId"), nodeId), cb.equal(root.get("componentId"), componentId)); + List results = entityManager.createQuery(cq).getResultList(); + return results.isEmpty() ? null : results.get(0); + } +} diff --git a/src/main/java/org/wise/portal/dao/work/StudentWorkDao.java b/src/main/java/org/wise/portal/dao/work/StudentWorkDao.java index a162cddfa..699ec1a40 100644 --- a/src/main/java/org/wise/portal/dao/work/StudentWorkDao.java +++ b/src/main/java/org/wise/portal/dao/work/StudentWorkDao.java @@ -58,4 +58,6 @@ List getWorkForComponentByPeriod(Run run, Group period, String node List getStudentWork(PeerGroup peerGroup, String nodeId, String componentId); List getStudentWork(Set workgroups, String nodeId, String componentId); + + List getStudentWorkListByIds(List ids); } diff --git a/src/main/java/org/wise/portal/dao/work/impl/HibernateStudentWorkDao.java b/src/main/java/org/wise/portal/dao/work/impl/HibernateStudentWorkDao.java index 73362f002..0220653ab 100644 --- a/src/main/java/org/wise/portal/dao/work/impl/HibernateStudentWorkDao.java +++ b/src/main/java/org/wise/portal/dao/work/impl/HibernateStudentWorkDao.java @@ -212,4 +212,17 @@ public List getStudentWork(Run run, Group period, String nodeId, TypedQuery query = entityManager.createQuery(cq); return (List) query.getResultList(); } + + @Override + public List getStudentWorkListByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return new ArrayList<>(); + } + CriteriaBuilder cb = getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(StudentWork.class); + Root studentWorkRoot = cq.from(StudentWork.class); + cq.select(studentWorkRoot).where(studentWorkRoot.get("id").in(ids)); + TypedQuery query = entityManager.createQuery(cq); + return query.getResultList(); + } } diff --git a/src/main/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIController.java b/src/main/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIController.java new file mode 100644 index 000000000..f09e3d405 --- /dev/null +++ b/src/main/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIController.java @@ -0,0 +1,160 @@ +package org.wise.portal.presentation.web.controllers.teacher; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.wise.portal.dao.ObjectNotFoundException; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.portal.domain.user.User; +import org.wise.portal.domain.workgroup.Workgroup; +import org.wise.portal.service.run.RunService; +import org.wise.portal.service.teacherpresentation.DiscussionItem; +import org.wise.portal.service.teacherpresentation.IdeasItem; +import org.wise.portal.service.teacherpresentation.IdeasResponse; +import org.wise.portal.service.teacherpresentation.TeacherPresentationConfigService; +import org.wise.portal.service.user.UserService; +import org.wise.portal.service.workgroup.WorkgroupService; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +/** + * REST API for TeacherPresentation: the teacher's selected student work, student name display + * setting, prompt and reflection answers for a component in a period. + */ +@RestController +@RequestMapping("/api/teacher/presentation-config") +@PreAuthorize("hasRole('ROLE_TEACHER')") +public class TeacherPresentationConfigAPIController { + + @Autowired + private TeacherPresentationConfigService teacherPresentationConfigService; + + @Autowired + private RunService runService; + + @Autowired + private UserService userService; + + @Autowired + private WorkgroupService workgroupService; + + public record Item(Integer studentWorkId, String ideaId, List responses) { + public Item(Integer studentWorkId) { + this(studentWorkId, null, null); + } + + public Item(String ideaId, List responses) { + this(null, ideaId, responses); + } + } + + public record ConfigRequest(Long runId, Long periodId, String nodeId, String componentId, + String componentType, String type, List items, String studentNamesDisplay, + Boolean showComponentPrompt, String prompt) { + } + + public record ConfigResponse(Long id, Long runId, Long periodId, String nodeId, + String componentId, String componentType, String type, List items, String studentNamesDisplay, + Boolean showComponentPrompt, String prompt, Long updatedByWorkgroupId, Long updatedAt) { + } + + @GetMapping + ConfigResponse getConfig(Authentication auth, @RequestParam Long runId, + @RequestParam Long periodId, @RequestParam String nodeId, + @RequestParam String componentId) throws ObjectNotFoundException { + Run run = runService.retrieveById(runId); + User user = userService.retrieveUserByUsername(auth.getName()); + if (!runService.isAllowedToViewStudentWork(run, user)) { + throw new AccessDeniedException("Not permitted"); + } + Group period = getPeriod(run, periodId); + TeacherPresentationConfig config = teacherPresentationConfigService.getConfig(run, period, + nodeId, componentId); + if (config == null) { + return new ConfigResponse(null, runId, periodId, nodeId, componentId, null, + null, new ArrayList<>(), + TeacherPresentationConfig.NAMES_HIDE, true, null, null, null); + } + return toResponse(config); + } + + @PutMapping + ConfigResponse saveConfig(Authentication auth, @RequestBody ConfigRequest request) + throws ObjectNotFoundException { + Run run = runService.retrieveById(request.runId()); + User user = userService.retrieveUserByUsername(auth.getName()); + if (!runService.isAllowedToGradeStudentWork(run, user)) { + throw new AccessDeniedException("Not permitted"); + } + Group period = getPeriod(run, request.periodId()); + String type = request.type() != null ? request.type() + : TeacherPresentationConfig.TYPE_DISCUSSION; + List itemsToSave; + if (TeacherPresentationConfig.TYPE_IDEAS.equals(type)) { + List ideasItems = new ArrayList<>(); + if (request.items() != null) { + for (Item item : request.items()) { + if (item != null) { + ideasItems.add(new IdeasItem(item.ideaId(), item.responses())); + } + } + } + itemsToSave = ideasItems; + } else { + List discussionItems = new ArrayList<>(); + if (request.items() != null) { + for (Item item : request.items()) { + if (item != null) { + discussionItems.add(new DiscussionItem(item.studentWorkId())); + } + } + } + itemsToSave = discussionItems; + } + try { + TeacherPresentationConfig config = teacherPresentationConfigService.saveConfig(run, period, + request.nodeId(), request.componentId(), request.componentType(), type, itemsToSave, + request.studentNamesDisplay(), request.showComponentPrompt(), request.prompt(), + getTeacherWorkgroup(run, user)); + return toResponse(config); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + + private Group getPeriod(Run run, Long periodId) { + for (Group period : run.getPeriods()) { + if (period.getId().equals(periodId)) { + return period; + } + } + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Period does not belong to the run"); + } + + private Workgroup getTeacherWorkgroup(Run run, User user) { + List workgroups = workgroupService.getWorkgroupListByRunAndUser(run, user); + return workgroups.isEmpty() ? null : workgroups.get(0); + } + + private ConfigResponse toResponse(TeacherPresentationConfig config) { + List items = teacherPresentationConfigService.getItems(config); + Workgroup updatedBy = config.getUpdatedByWorkgroup(); + return new ConfigResponse(config.getId(), config.getRun().getId(), config.getPeriod().getId(), + config.getNodeId(), config.getComponentId(), config.getComponentType(), + config.getType(), items, + config.getStudentNamesDisplay(), config.isShowComponentPrompt(), config.getPrompt(), + updatedBy == null ? null : updatedBy.getId(), config.getUpdatedAt().getTime()); + } +} diff --git a/src/main/java/org/wise/portal/service/teacherpresentation/DiscussionItem.java b/src/main/java/org/wise/portal/service/teacherpresentation/DiscussionItem.java new file mode 100644 index 000000000..b982ead5f --- /dev/null +++ b/src/main/java/org/wise/portal/service/teacherpresentation/DiscussionItem.java @@ -0,0 +1,4 @@ +package org.wise.portal.service.teacherpresentation; + +public record DiscussionItem(Integer studentWorkId) { +} diff --git a/src/main/java/org/wise/portal/service/teacherpresentation/IdeasItem.java b/src/main/java/org/wise/portal/service/teacherpresentation/IdeasItem.java new file mode 100644 index 000000000..a04455a28 --- /dev/null +++ b/src/main/java/org/wise/portal/service/teacherpresentation/IdeasItem.java @@ -0,0 +1,11 @@ +package org.wise.portal.service.teacherpresentation; + +import java.util.List; + +public record IdeasItem(String ideaId, List responses) { + public IdeasItem { + if (responses == null) { + responses = List.of(); + } + } +} diff --git a/src/main/java/org/wise/portal/service/teacherpresentation/IdeasResponse.java b/src/main/java/org/wise/portal/service/teacherpresentation/IdeasResponse.java new file mode 100644 index 000000000..77ea0ee28 --- /dev/null +++ b/src/main/java/org/wise/portal/service/teacherpresentation/IdeasResponse.java @@ -0,0 +1,11 @@ +package org.wise.portal.service.teacherpresentation; + +/** + * A student response selected for presentation under an idea category. Dialog Guidance work is + * additive (each save contains the whole chat), so a response is identified by workgroupId and + * responseTimestamp (the time of the chat message). responseTimestamp is null for single response + * work, which is identified by studentWorkId. text is a snapshot of the response. + */ +public record IdeasResponse(Integer studentWorkId, Long workgroupId, Long responseTimestamp, + String text) { +} diff --git a/src/main/java/org/wise/portal/service/teacherpresentation/TeacherPresentationConfigService.java b/src/main/java/org/wise/portal/service/teacherpresentation/TeacherPresentationConfigService.java new file mode 100644 index 000000000..5f3ccd46e --- /dev/null +++ b/src/main/java/org/wise/portal/service/teacherpresentation/TeacherPresentationConfigService.java @@ -0,0 +1,44 @@ +package org.wise.portal.service.teacherpresentation; + +import java.util.List; + +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.portal.domain.workgroup.Workgroup; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +public interface TeacherPresentationConfigService { + + int MAX_ITEMS = 500; + int MAX_PROMPT_LENGTH = 1000; + + /** + * @return the config, or null if the teacher has not saved one yet + */ + TeacherPresentationConfig getConfig(Run run, Group period, String nodeId, String componentId); + + List getStudentWorkIds(TeacherPresentationConfig config); + + List getItems(TeacherPresentationConfig config); + + /** + * Creates or updates the config for the run, period, node and component with type. + * + * @param type one of Discussion or Ideas. + * @param items list of DiscussionItem or IdeasItem. + * @param prompt the teacher's prompt. null or blank resets to the component default. + * @param studentNamesDisplay one of show, hide or anonymize. null keeps the current value (or + * hide for a new config). + * @throws IllegalArgumentException if the input is invalid + */ + TeacherPresentationConfig saveConfig(Run run, Group period, String nodeId, String componentId, + String componentType, String type, List items, String studentNamesDisplay, + Boolean showComponentPrompt, String prompt, Workgroup teacherWorkgroup); + + default TeacherPresentationConfig saveConfig(Run run, Group period, String nodeId, + String componentId, String componentType, String type, List items, + String studentNamesDisplay, String prompt, Workgroup teacherWorkgroup) { + return saveConfig(run, period, nodeId, componentId, componentType, type, items, + studentNamesDisplay, null, prompt, teacherWorkgroup); + } +} diff --git a/src/main/java/org/wise/portal/service/teacherpresentation/TeacherPresentationItemStrategy.java b/src/main/java/org/wise/portal/service/teacherpresentation/TeacherPresentationItemStrategy.java new file mode 100644 index 000000000..979f3e3c8 --- /dev/null +++ b/src/main/java/org/wise/portal/service/teacherpresentation/TeacherPresentationItemStrategy.java @@ -0,0 +1,60 @@ +package org.wise.portal.service.teacherpresentation; + +import java.util.List; + +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Strategy interface defining serialization, deserialization, validation, and ID extraction + * for different teacher presentation item types (e.g. Discussion, Ideas). + * + * @param the typed item domain class + */ +public interface TeacherPresentationItemStrategy { + + /** + * Returns the unique presentation config type string handled by this strategy + * (e.g. "Discussion", "Ideas"). + */ + String getType(); + + /** + * Deserializes the JSON items string into a typed list of items. + * + * @param json the serialized JSON items string + * @param objectMapper the Jackson object mapper + * @return list of parsed items, or empty list on null/empty/invalid input + */ + List parseItems(String json, ObjectMapper objectMapper); + + /** + * Serializes a list of items into a JSON string. + * + * @param items the list of typed items to serialize + * @param objectMapper the Jackson object mapper + * @return serialized JSON string + */ + String serializeItems(List items, ObjectMapper objectMapper); + + /** + * Validates and normalizes raw input items (e.g. from service or controller). + * + * @param run the active run + * @param period the active period group + * @param rawItems the raw input items list + * @return list of normalized typed items + * @throws IllegalArgumentException if items fail validation rules + */ + List validateAndNormalize(Run run, Group period, List rawItems); + + /** + * Extracts student work IDs referenced by the given items. + * + * @param items the list of typed items + * @return list of student work IDs + */ + List extractStudentWorkIds(List items); +} diff --git a/src/main/java/org/wise/portal/service/teacherpresentation/impl/AbstractTeacherPresentationItemStrategy.java b/src/main/java/org/wise/portal/service/teacherpresentation/impl/AbstractTeacherPresentationItemStrategy.java new file mode 100644 index 000000000..f635ab491 --- /dev/null +++ b/src/main/java/org/wise/portal/service/teacherpresentation/impl/AbstractTeacherPresentationItemStrategy.java @@ -0,0 +1,74 @@ +package org.wise.portal.service.teacherpresentation.impl; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.wise.portal.dao.work.StudentWorkDao; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.portal.service.teacherpresentation.TeacherPresentationConfigService; +import org.wise.portal.service.teacherpresentation.TeacherPresentationItemStrategy; +import org.wise.vle.domain.work.StudentWork; + +/** + * Base abstract strategy providing shared student work validation logic across presentation types. + * + * @param the typed item domain class + */ +public abstract class AbstractTeacherPresentationItemStrategy + implements TeacherPresentationItemStrategy { + + protected StudentWorkDao studentWorkDao; + + public AbstractTeacherPresentationItemStrategy(StudentWorkDao studentWorkDao) { + this.studentWorkDao = studentWorkDao; + } + + /** + * Validates student work IDs against run and period ownership using batch fetching. + * + * @param run the active run + * @param period the active period group + * @param ids the list of student work IDs to validate + * @return set of unique validated student work IDs preserving insertion order + * @throws IllegalArgumentException if an ID is null, unknown, belongs to another run/period, + * or exceeds MAX_ITEMS limit + */ + protected Set validateStudentWorkIds(Run run, Group period, List ids) { + Set uniqueIds = new LinkedHashSet<>(); + if (ids != null) { + uniqueIds.addAll(ids); + } + if (uniqueIds.size() > TeacherPresentationConfigService.MAX_ITEMS) { + throw new IllegalArgumentException("Too many items. The maximum is " + + TeacherPresentationConfigService.MAX_ITEMS); + } + for (Integer id : uniqueIds) { + if (id == null) { + throw new IllegalArgumentException("Invalid studentWorkId"); + } + } + if (uniqueIds.isEmpty()) { + return uniqueIds; + } + List works = studentWorkDao.getStudentWorkListByIds(new ArrayList<>(uniqueIds)); + Map workMap = new HashMap<>(); + for (StudentWork work : works) { + if (work != null && work.getId() != null) { + workMap.put(work.getId(), work); + } + } + for (Integer id : uniqueIds) { + StudentWork work = workMap.get(id); + if (work == null || !work.getRun().getId().equals(run.getId()) + || !work.getPeriod().getId().equals(period.getId())) { + throw new IllegalArgumentException("Unknown studentWorkId: " + id); + } + } + return uniqueIds; + } +} diff --git a/src/main/java/org/wise/portal/service/teacherpresentation/impl/DiscussionPresentationItemStrategy.java b/src/main/java/org/wise/portal/service/teacherpresentation/impl/DiscussionPresentationItemStrategy.java new file mode 100644 index 000000000..ebc5e07e2 --- /dev/null +++ b/src/main/java/org/wise/portal/service/teacherpresentation/impl/DiscussionPresentationItemStrategy.java @@ -0,0 +1,91 @@ +package org.wise.portal.service.teacherpresentation.impl; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.wise.portal.dao.work.StudentWorkDao; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.portal.service.teacherpresentation.DiscussionItem; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; +import org.wise.vle.domain.work.StudentWork; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Component +public class DiscussionPresentationItemStrategy + extends AbstractTeacherPresentationItemStrategy { + + @Autowired + public DiscussionPresentationItemStrategy(StudentWorkDao studentWorkDao) { + super(studentWorkDao); + } + + @Override + public String getType() { + return TeacherPresentationConfig.TYPE_DISCUSSION; + } + + @Override + public List parseItems(String json, ObjectMapper objectMapper) { + if (json == null || json.isBlank()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (JsonProcessingException e) { + return Collections.emptyList(); + } + } + + @Override + public String serializeItems(List items, ObjectMapper objectMapper) { + try { + return objectMapper.writeValueAsString(items); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + @Override + public List validateAndNormalize(Run run, Group period, List rawItems) { + List ids = new ArrayList<>(); + if (rawItems != null) { + for (Object obj : rawItems) { + if (obj instanceof DiscussionItem di) { + ids.add(di.studentWorkId()); + } else if (obj instanceof Integer id) { + ids.add(id); + } else if (obj != null) { + throw new IllegalArgumentException("Invalid discussion item type: " + obj.getClass()); + } + } + } + Set uniqueIds = validateStudentWorkIds(run, period, ids); + List normalized = new ArrayList<>(); + for (Integer id : uniqueIds) { + normalized.add(new DiscussionItem(id)); + } + return normalized; + } + + @Override + public List extractStudentWorkIds(List items) { + List ids = new ArrayList<>(); + if (items != null) { + for (DiscussionItem item : items) { + if (item != null && item.studentWorkId() != null) { + ids.add(item.studentWorkId()); + } + } + } + return ids; + } +} diff --git a/src/main/java/org/wise/portal/service/teacherpresentation/impl/IdeasPresentationItemStrategy.java b/src/main/java/org/wise/portal/service/teacherpresentation/impl/IdeasPresentationItemStrategy.java new file mode 100644 index 000000000..7fa6e07f4 --- /dev/null +++ b/src/main/java/org/wise/portal/service/teacherpresentation/impl/IdeasPresentationItemStrategy.java @@ -0,0 +1,141 @@ +package org.wise.portal.service.teacherpresentation.impl; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.wise.portal.dao.work.StudentWorkDao; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.portal.service.teacherpresentation.IdeasItem; +import org.wise.portal.service.teacherpresentation.IdeasResponse; +import org.wise.portal.service.teacherpresentation.TeacherPresentationConfigService; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; +import org.wise.vle.domain.work.StudentWork; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Component +public class IdeasPresentationItemStrategy + extends AbstractTeacherPresentationItemStrategy { + + private static final int MAX_RESPONSE_TEXT_LENGTH = 2000; + // items column is a MySQL text column (65535 bytes) + private static final int MAX_SERIALIZED_ITEMS_LENGTH = 60000; + + @Autowired + public IdeasPresentationItemStrategy(StudentWorkDao studentWorkDao) { + super(studentWorkDao); + } + + @Override + public String getType() { + return TeacherPresentationConfig.TYPE_IDEAS; + } + + @Override + public List parseItems(String json, ObjectMapper objectMapper) { + if (json == null || json.isBlank()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (JsonProcessingException e) { + return Collections.emptyList(); + } + } + + @Override + public String serializeItems(List items, ObjectMapper objectMapper) { + try { + String serialized = objectMapper.writeValueAsString(items); + if (serialized.getBytes(StandardCharsets.UTF_8).length > MAX_SERIALIZED_ITEMS_LENGTH) { + throw new IllegalArgumentException("Too many selected responses"); + } + return serialized; + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + @Override + public List validateAndNormalize(Run run, Group period, List rawItems) { + if (rawItems == null) { + return Collections.emptyList(); + } + if (rawItems.size() > TeacherPresentationConfigService.MAX_ITEMS) { + throw new IllegalArgumentException("Too many items. The maximum is " + + TeacherPresentationConfigService.MAX_ITEMS); + } + List normalized = new ArrayList<>(); + List allStudentWorkIds = new ArrayList<>(); + int responseCount = 0; + for (Object obj : rawItems) { + if (obj instanceof IdeasItem ii) { + String ideaId = ii.ideaId(); + if (ideaId != null) { + ideaId = ideaId.trim(); + if (ideaId.isEmpty() || ideaId.length() > 100) { + throw new IllegalArgumentException("Invalid ideaId: " + ideaId); + } + } + if (ideaId == null && ii.responses().isEmpty()) { + throw new IllegalArgumentException("Item must specify ideaId or responses"); + } + List responses = new ArrayList<>(); + for (IdeasResponse response : ii.responses()) { + responses.add(normalizeIdeasResponse(response)); + allStudentWorkIds.add(response.studentWorkId()); + } + responseCount += responses.size(); + normalized.add(new IdeasItem(ideaId, responses)); + } else if (obj != null) { + throw new IllegalArgumentException("Invalid ideas item type: " + obj.getClass()); + } + } + if (responseCount > TeacherPresentationConfigService.MAX_ITEMS) { + throw new IllegalArgumentException("Too many responses. The maximum is " + + TeacherPresentationConfigService.MAX_ITEMS); + } + validateStudentWorkIds(run, period, allStudentWorkIds); + return normalized; + } + + @Override + public List extractStudentWorkIds(List items) { + List ids = new ArrayList<>(); + if (items != null) { + for (IdeasItem ii : items) { + if (ii != null && ii.responses() != null) { + for (IdeasResponse response : ii.responses()) { + if (response != null && response.studentWorkId() != null) { + ids.add(response.studentWorkId()); + } + } + } + } + } + return ids; + } + + private IdeasResponse normalizeIdeasResponse(IdeasResponse response) { + if (response == null || response.studentWorkId() == null) { + throw new IllegalArgumentException("Invalid studentWorkId"); + } + if (response.workgroupId() == null) { + throw new IllegalArgumentException("Invalid workgroupId"); + } + String text = response.text() != null ? response.text() : ""; + if (text.length() > MAX_RESPONSE_TEXT_LENGTH) { + text = text.substring(0, MAX_RESPONSE_TEXT_LENGTH); + } + return new IdeasResponse(response.studentWorkId(), response.workgroupId(), + response.responseTimestamp(), text); + } +} diff --git a/src/main/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImpl.java b/src/main/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImpl.java new file mode 100644 index 000000000..085e20ab9 --- /dev/null +++ b/src/main/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImpl.java @@ -0,0 +1,192 @@ +package org.wise.portal.service.teacherpresentation.impl; + +import java.sql.Timestamp; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.wise.portal.dao.teacherpresentation.TeacherPresentationConfigDao; +import org.wise.portal.dao.work.StudentWorkDao; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.portal.domain.workgroup.Workgroup; +import org.wise.portal.service.teacherpresentation.TeacherPresentationConfigService; +import org.wise.portal.service.teacherpresentation.TeacherPresentationItemStrategy; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; +import org.wise.vle.domain.work.StudentWork; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@Service +public class TeacherPresentationConfigServiceImpl implements TeacherPresentationConfigService { + + private static final int MAX_ID_LENGTH = 30; + private static final Set NAMES_DISPLAY_OPTIONS = Set.of( + TeacherPresentationConfig.NAMES_SHOW, TeacherPresentationConfig.NAMES_HIDE, + TeacherPresentationConfig.NAMES_ANONYMIZE); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private TeacherPresentationConfigDao teacherPresentationConfigDao; + + @Autowired + private StudentWorkDao studentWorkDao; + + private final Map> strategies = new HashMap<>(); + + @Autowired + public void setStrategies(List> strategyList) { + this.strategies.clear(); + if (strategyList != null) { + for (TeacherPresentationItemStrategy strategy : strategyList) { + this.strategies.put(strategy.getType(), strategy); + } + } + } + + @Override + @Transactional(readOnly = true) + public TeacherPresentationConfig getConfig(Run run, Group period, String nodeId, + String componentId) { + return teacherPresentationConfigDao.getConfig(run, period, nodeId, componentId); + } + + @Override + public List getItems(TeacherPresentationConfig config) { + if (config == null || config.getItems() == null) { + return Collections.emptyList(); + } + String resolvedType = resolveType(config.getType()); + TeacherPresentationItemStrategy strategy = getStrategy(resolvedType); + if (strategy == null) { + return Collections.emptyList(); + } + return strategy.parseItems(config.getItems(), objectMapper); + } + + @Override + public List getStudentWorkIds(TeacherPresentationConfig config) { + if (config == null || config.getItems() == null) { + return Collections.emptyList(); + } + String resolvedType = resolveType(config.getType()); + TeacherPresentationItemStrategy strategy = getStrategy(resolvedType); + if (strategy == null) { + return Collections.emptyList(); + } + return parseAndExtractStudentWorkIds(strategy, config.getItems()); + } + + @Override + @Transactional + public TeacherPresentationConfig saveConfig(Run run, Group period, String nodeId, + String componentId, String componentType, String type, List items, + String studentNamesDisplay, Boolean showComponentPrompt, String prompt, + Workgroup teacherWorkgroup) { + validateComponent(nodeId, componentId, componentType); + String resolvedType = resolveType(type); + TeacherPresentationItemStrategy strategy = getStrategy(resolvedType); + if (strategy == null) { + throw new IllegalArgumentException("Invalid type: " + type); + } + if (studentNamesDisplay != null && !NAMES_DISPLAY_OPTIONS.contains(studentNamesDisplay)) { + throw new IllegalArgumentException("Invalid studentNamesDisplay: " + studentNamesDisplay); + } + String normalizedPrompt = normalizePrompt(prompt); + + String serializedItems = validateAndSerialize(strategy, run, period, items); + + TeacherPresentationConfig config = getOrCreateConfig(run, period, nodeId, componentId, + componentType, resolvedType); + config.setComponentType(componentType); + config.setType(resolvedType); + config.setItems(serializedItems); + if (studentNamesDisplay != null) { + config.setStudentNamesDisplay(studentNamesDisplay); + } + if (showComponentPrompt != null) { + config.setShowComponentPrompt(showComponentPrompt); + } + config.setPrompt(normalizedPrompt); + config.setUpdatedByWorkgroup(teacherWorkgroup); + config.setUpdatedAt(new Timestamp(System.currentTimeMillis())); + teacherPresentationConfigDao.save(config); + return config; + } + + private TeacherPresentationItemStrategy getStrategy(String type) { + TeacherPresentationItemStrategy strategy = strategies.get(type); + if (strategy == null) { + if (TeacherPresentationConfig.TYPE_DISCUSSION.equals(type)) { + return new DiscussionPresentationItemStrategy(studentWorkDao); + } else if (TeacherPresentationConfig.TYPE_IDEAS.equals(type)) { + return new IdeasPresentationItemStrategy(studentWorkDao); + } + } + return strategy; + } + + private String validateAndSerialize(TeacherPresentationItemStrategy strategy, Run run, + Group period, List items) { + List normalized = strategy.validateAndNormalize(run, period, items); + return strategy.serializeItems(normalized, objectMapper); + } + + private List parseAndExtractStudentWorkIds( + TeacherPresentationItemStrategy strategy, String itemsJson) { + List items = strategy.parseItems(itemsJson, objectMapper); + return strategy.extractStudentWorkIds(items); + } + + private String resolveType(String type) { + return type != null ? type : TeacherPresentationConfig.TYPE_DISCUSSION; + } + + private TeacherPresentationConfig getOrCreateConfig(Run run, Group period, String nodeId, + String componentId, String componentType, String type) { + TeacherPresentationConfig config = teacherPresentationConfigDao.getConfig(run, period, nodeId, + componentId); + if (config == null) { + Timestamp now = new Timestamp(System.currentTimeMillis()); + config = new TeacherPresentationConfig(); + config.setRun(run); + config.setPeriod(period); + config.setNodeId(nodeId); + config.setComponentId(componentId); + config.setComponentType(componentType); + config.setType(type); + config.setCreatedAt(now); + config.setUpdatedAt(now); + } + return config; + } + + private void validateComponent(String nodeId, String componentId, String componentType) { + if (nodeId == null || nodeId.isBlank() || nodeId.length() > MAX_ID_LENGTH) { + throw new IllegalArgumentException("Invalid nodeId"); + } + if (componentId == null || componentId.isBlank() || componentId.length() > MAX_ID_LENGTH) { + throw new IllegalArgumentException("Invalid componentId"); + } + if (componentType == null || componentType.isBlank() + || componentType.length() > MAX_ID_LENGTH) { + throw new IllegalArgumentException("Invalid componentType"); + } + } + + private String normalizePrompt(String prompt) { + if (prompt == null || prompt.isBlank()) { + return null; + } + if (prompt.length() > MAX_PROMPT_LENGTH) { + throw new IllegalArgumentException("prompt is too long"); + } + return prompt; + } +} diff --git a/src/main/java/org/wise/vle/domain/teacherpresentation/TeacherPresentationConfig.java b/src/main/java/org/wise/vle/domain/teacherpresentation/TeacherPresentationConfig.java new file mode 100644 index 000000000..35aafd0b1 --- /dev/null +++ b/src/main/java/org/wise/vle/domain/teacherpresentation/TeacherPresentationConfig.java @@ -0,0 +1,98 @@ +package org.wise.vle.domain.teacherpresentation; + +import java.sql.Timestamp; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.Setter; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.group.impl.PersistentGroup; +import org.wise.portal.domain.run.Run; +import org.wise.portal.domain.run.impl.RunImpl; +import org.wise.portal.domain.workgroup.Workgroup; +import org.wise.portal.domain.workgroup.impl.WorkgroupImpl; + +/** + * A teacher's TeacherPresentation configuration for one component in one period of a run. It + * stores which student work items (e.g. discussion posts and comments) the teacher chose to + * present, how student names are displayed, and an optional override of the default prompt. + */ +@Entity(name = "teacherPresentationConfig") +@Table(name = "teacher_presentation_config", + uniqueConstraints = @UniqueConstraint(name = "teacherPresentationConfigUniqueKey", + columnNames = { "runId", "periodId", "nodeId", "componentId" }), + indexes = @Index(columnList = "runId", name = "teacherPresentationConfigRunIdIndex")) +@Getter +@Setter +public class TeacherPresentationConfig { + + public static final String NAMES_SHOW = "show"; + public static final String NAMES_HIDE = "hide"; + public static final String NAMES_ANONYMIZE = "anonymize"; + + public static final String TYPE_DISCUSSION = "Discussion"; + public static final String TYPE_IDEAS = "Ideas"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(targetEntity = RunImpl.class, fetch = FetchType.LAZY) + @JoinColumn(name = "runId", nullable = false) + @JsonIgnore + private Run run; + + @ManyToOne(targetEntity = PersistentGroup.class, fetch = FetchType.LAZY) + @JoinColumn(name = "periodId", nullable = false) + @JsonIgnore + private Group period; + + @Column(name = "nodeId", length = 30, nullable = false) + private String nodeId; + + @Column(name = "componentId", length = 30, nullable = false) + private String componentId; + + @Column(name = "componentType", length = 30, nullable = false) + private String componentType; + + @Column(name = "`type`", length = 30, nullable = false) + private String type = TYPE_DISCUSSION; + + /** JSON array of the form [{"studentWorkId": 123}, ...] */ + @Column(name = "items", length = 65536, columnDefinition = "text", nullable = false) + private String items = "[]"; + + @Column(name = "studentNamesDisplay", length = 30, nullable = false) + private String studentNamesDisplay = NAMES_HIDE; + + @Column(name = "showComponentPrompt", nullable = false) + private boolean showComponentPrompt = true; + + /** The teacher's edited prompt. null means use the component's default prompt. */ + @Column(name = "prompt", length = 65536, columnDefinition = "text") + private String prompt; + + @ManyToOne(targetEntity = WorkgroupImpl.class, fetch = FetchType.LAZY) + @JoinColumn(name = "updatedByWorkgroupId") + @JsonIgnore + private Workgroup updatedByWorkgroup; + + @Column(name = "createdAt", nullable = false) + private Timestamp createdAt; + + @Column(name = "updatedAt", nullable = false) + private Timestamp updatedAt; +} diff --git a/src/main/resources/db/migration/V3__create_teacher_presentation_tables.sql b/src/main/resources/db/migration/V3__create_teacher_presentation_tables.sql new file mode 100644 index 000000000..23efd8953 --- /dev/null +++ b/src/main/resources/db/migration/V3__create_teacher_presentation_tables.sql @@ -0,0 +1,22 @@ +create table teacher_presentation_config ( + id bigint not null auto_increment, + runId bigint not null, + periodId bigint not null, + nodeId varchar(30) not null, + componentId varchar(30) not null, + componentType varchar(30) not null, + `type` varchar(30) not null, + items text not null, + studentNamesDisplay varchar(30) not null default 'hide', + showComponentPrompt boolean not null default true, + prompt text, + updatedByWorkgroupId bigint, + createdAt datetime(3) not null, + updatedAt datetime(3) not null, + primary key (id), + unique key teacherPresentationConfigUniqueKey (runId, periodId, nodeId, componentId), + index teacherPresentationConfigRunIdIndex (runId), + constraint teacherPresentationConfigRunIdFK foreign key (runId) references runs (id), + constraint teacherPresentationConfigPeriodIdFK foreign key (periodId) references `groups` (id), + constraint teacherPresentationConfigUpdatedByWorkgroupIdFK foreign key (updatedByWorkgroupId) references workgroups (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/src/test/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDaoTest.java b/src/test/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDaoTest.java new file mode 100644 index 000000000..584e28d87 --- /dev/null +++ b/src/test/java/org/wise/portal/dao/teacherpresentation/impl/HibernateTeacherPresentationConfigDaoTest.java @@ -0,0 +1,132 @@ +package org.wise.portal.dao.teacherpresentation.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.sql.Timestamp; +import java.util.Calendar; +import java.util.Date; +import java.util.Set; +import java.util.TreeSet; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.wise.portal.domain.authentication.Schoollevel; +import org.wise.portal.domain.group.Group; +import org.wise.portal.domain.run.Run; +import org.wise.portal.domain.user.User; +import org.wise.portal.domain.workgroup.Workgroup; +import org.wise.portal.junit.AbstractTransactionalDbTests; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +@SpringBootTest +public class HibernateTeacherPresentationConfigDaoTest extends AbstractTransactionalDbTests { + + private Run run; + private Run otherRun; + private Group period1; + private Group period2; + private Workgroup teacherWorkgroup; + + @Autowired + private HibernateTeacherPresentationConfigDao teacherPresentationConfigDao; + + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + + Long projectId1 = getNextAvailableProjectId(); + Long projectId2 = getNextAvailableProjectId(); + Date startTime = Calendar.getInstance().getTime(); + + User teacher = createTeacherUser("Albus", "Dumbledore", "adumbledore", "Prof. Dumbledore", + "secret", "Hogwarts", "Scotland", "UK", "albus@hogwarts.edu", "Hogwarts School", + Schoollevel.COLLEGE, "google-teacher-1"); + + run = createProjectAndRun(projectId1, "Potions 101", teacher, startTime, "POT101"); + otherRun = createProjectAndRun(projectId2, "Defense Against the Dark Arts", teacher, startTime, + "DADA101"); + + period1 = createPeriod("Period 1"); + period2 = createPeriod("Period 2"); + + Set periods = new TreeSet<>(); + periods.add(period1); + periods.add(period2); + run.setPeriods(periods); + + teacherWorkgroup = addUserToRun(teacher, run, period1); + } + + private TeacherPresentationConfig createConfig(Run run, Group period, String nodeId, + String componentId, String componentType) { + TeacherPresentationConfig config = new TeacherPresentationConfig(); + config.setRun(run); + config.setPeriod(period); + config.setNodeId(nodeId); + config.setComponentId(componentId); + config.setComponentType(componentType); + config.setItems("[{\"studentWorkId\":101}]"); + config.setStudentNamesDisplay(TeacherPresentationConfig.NAMES_HIDE); + config.setPrompt("Sample discussion prompt"); + config.setUpdatedByWorkgroup(teacherWorkgroup); + Timestamp now = new Timestamp(System.currentTimeMillis()); + config.setCreatedAt(now); + config.setUpdatedAt(now); + teacherPresentationConfigDao.save(config); + return config; + } + + @Test + public void getConfig_WhenNoneExists_ReturnsNull() { + TeacherPresentationConfig config = teacherPresentationConfigDao.getConfig(run, period1, + "node1", "component1"); + assertNull(config); + } + + @Test + public void saveAndGetConfig_WhenExists_ReturnsMatchingConfig() { + TeacherPresentationConfig saved = createConfig(run, period1, "node1", "component1", + "Discussion"); + + TeacherPresentationConfig retrieved = teacherPresentationConfigDao.getConfig(run, period1, + "node1", "component1"); + assertNotNull(retrieved); + assertEquals(saved.getId(), retrieved.getId()); + assertEquals(run.getId(), retrieved.getRun().getId()); + assertEquals(period1.getId(), retrieved.getPeriod().getId()); + assertEquals("node1", retrieved.getNodeId()); + assertEquals("component1", retrieved.getComponentId()); + assertEquals("Discussion", retrieved.getComponentType()); + assertEquals("[{\"studentWorkId\":101}]", retrieved.getItems()); + assertEquals(TeacherPresentationConfig.NAMES_HIDE, retrieved.getStudentNamesDisplay()); + assertEquals(true, retrieved.isShowComponentPrompt()); + assertEquals("Sample discussion prompt", retrieved.getPrompt()); + assertEquals(teacherWorkgroup.getId(), retrieved.getUpdatedByWorkgroup().getId()); + } + + @Test + public void getConfig_WithDifferentParameters_ReturnsNull() { + createConfig(run, period1, "node1", "component1", "Discussion"); + + assertNull(teacherPresentationConfigDao.getConfig(otherRun, period1, "node1", "component1")); + assertNull(teacherPresentationConfigDao.getConfig(run, period2, "node1", "component1")); + assertNull(teacherPresentationConfigDao.getConfig(run, period1, "otherNode", "component1")); + assertNull(teacherPresentationConfigDao.getConfig(run, period1, "node1", "otherComponent")); + } + + @Test + public void delete_Config_RemovesConfig() { + TeacherPresentationConfig config = createConfig(run, period1, "node1", "component1", + "Discussion"); + flush(); + + teacherPresentationConfigDao.delete(config); + flush(); + + assertNull(teacherPresentationConfigDao.getConfig(run, period1, "node1", "component1")); + } +} diff --git a/src/test/java/org/wise/portal/dao/work/impl/HibernateStudentWorkDaoTest.java b/src/test/java/org/wise/portal/dao/work/impl/HibernateStudentWorkDaoTest.java index 5d9a30eb1..de97142a1 100644 --- a/src/test/java/org/wise/portal/dao/work/impl/HibernateStudentWorkDaoTest.java +++ b/src/test/java/org/wise/portal/dao/work/impl/HibernateStudentWorkDaoTest.java @@ -26,7 +26,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.sql.Timestamp; +import java.util.Arrays; import java.util.Calendar; +import java.util.Collections; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -173,6 +175,23 @@ public void getStudentWork_FromAllPeriods_ShouldReturnWork() { assertEquals(DUMMY_STUDENT_WORK3, getStudentData(studentWorkList, 1)); } + @Test + public void getStudentWorkListByIds_ValidIds_ShouldReturnStudentWork() { + StudentWork sw1 = createStudentWork(workgroup1, peerGroup1, "nodeX", "compX", "Work X"); + StudentWork sw2 = createStudentWork(workgroup1, peerGroup1, "nodeY", "compY", "Work Y"); + + List results = + studentWorkDao.getStudentWorkListByIds(Arrays.asList(sw1.getId(), sw2.getId())); + assertEquals(2, results.size()); + + List emptyResults = + studentWorkDao.getStudentWorkListByIds(Collections.emptyList()); + assertEquals(0, emptyResults.size()); + + List nullResults = studentWorkDao.getStudentWorkListByIds(null); + assertEquals(0, nullResults.size()); + } + private StudentWork createStudentWork(Workgroup workgroup, PeerGroup peerGroup, String nodeId, String componentId, String studentData) { StudentWork studentWork = new StudentWork(); diff --git a/src/test/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIControllerTest.java b/src/test/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIControllerTest.java new file mode 100644 index 000000000..75f590c09 --- /dev/null +++ b/src/test/java/org/wise/portal/presentation/web/controllers/teacher/TeacherPresentationConfigAPIControllerTest.java @@ -0,0 +1,302 @@ +package org.wise.portal.presentation.web.controllers.teacher; + +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.isNull; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.easymock.EasyMockExtension; +import org.easymock.Mock; +import org.easymock.TestSubject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.server.ResponseStatusException; +import org.wise.portal.dao.ObjectNotFoundException; +import org.wise.portal.presentation.web.controllers.APIControllerTest; +import org.wise.portal.presentation.web.controllers.teacher.TeacherPresentationConfigAPIController.ConfigRequest; +import org.wise.portal.presentation.web.controllers.teacher.TeacherPresentationConfigAPIController.ConfigResponse; +import org.wise.portal.presentation.web.controllers.teacher.TeacherPresentationConfigAPIController.Item; +import org.wise.portal.service.teacherpresentation.DiscussionItem; +import org.wise.portal.service.teacherpresentation.IdeasItem; +import org.wise.portal.service.teacherpresentation.IdeasResponse; +import org.wise.portal.service.teacherpresentation.TeacherPresentationConfigService; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; + +@ExtendWith(EasyMockExtension.class) +public class TeacherPresentationConfigAPIControllerTest extends APIControllerTest { + + @TestSubject + private TeacherPresentationConfigAPIController controller = new TeacherPresentationConfigAPIController(); + + @Mock + private TeacherPresentationConfigService teacherPresentationConfigService; + + private void expectTeacherWithPermissions(boolean canView, boolean canGrade) + throws ObjectNotFoundException { + expect(runService.retrieveById(runId1)).andReturn(run1).anyTimes(); + expect(userService.retrieveUserByUsername(teacherAuth.getName())).andReturn(teacher1) + .anyTimes(); + expect(runService.isAllowedToViewStudentWork(run1, teacher1)).andReturn(canView).anyTimes(); + expect(runService.isAllowedToGradeStudentWork(run1, teacher1)).andReturn(canGrade).anyTimes(); + expect(workgroupService.getWorkgroupListByRunAndUser(run1, teacher1)) + .andReturn(Arrays.asList(teacher1Run1Workgroup)).anyTimes(); + } + + private ConfigRequest request(Long periodId, List items, String namesDisplay, + String prompt) { + return new ConfigRequest(runId1, periodId, run1Node1Id, run1Component1Id, "Discussion", + TeacherPresentationConfig.TYPE_DISCUSSION, items, namesDisplay, true, prompt); + } + + private ConfigRequest ideasRequest(Long periodId, List items, String namesDisplay, + String prompt) { + return new ConfigRequest(runId1, periodId, run1Node1Id, run1Component1Id, "OpenResponse", + TeacherPresentationConfig.TYPE_IDEAS, items, namesDisplay, true, prompt); + } + + @Test + public void getConfig_NoConfigSaved_ReturnDefaults() throws Exception { + expectTeacherWithPermissions(true, true); + expect(teacherPresentationConfigService.getConfig(run1, run1Period1, run1Node1Id, + run1Component1Id)).andReturn(null); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ConfigResponse response = controller.getConfig(teacherAuth, runId1, run1Period1Id, + run1Node1Id, run1Component1Id); + assertTrue(response.items().isEmpty()); + assertNull(response.type()); + assertEquals("hide", response.studentNamesDisplay()); + assertTrue(response.showComponentPrompt()); + assertNull(response.prompt()); + verify(teacherPresentationConfigService); + } + + @Test + public void getConfig_NoViewPermission_ThrowAccessDenied() throws Exception { + expectTeacherWithPermissions(false, false); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + assertThrows(AccessDeniedException.class, () -> controller.getConfig(teacherAuth, runId1, + run1Period1Id, run1Node1Id, run1Component1Id)); + } + + @Test + public void getConfig_PeriodNotInRun_ThrowBadRequest() throws Exception { + expectTeacherWithPermissions(true, true); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ResponseStatusException e = assertThrows(ResponseStatusException.class, + () -> controller.getConfig(teacherAuth, runId1, 99999L, run1Node1Id, run1Component1Id)); + assertEquals(HttpStatus.BAD_REQUEST, e.getStatusCode()); + } + + @Test + public void saveConfig_NoGradePermission_ThrowAccessDenied() throws Exception { + expectTeacherWithPermissions(true, false); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + assertThrows(AccessDeniedException.class, () -> controller.saveConfig(teacherAuth, + request(run1Period1Id, Arrays.asList(new Item(1)), "hide", null))); + } + + @Test + public void saveConfig_ServiceRejectsInput_ThrowBadRequest() throws Exception { + expectTeacherWithPermissions(true, true); + expect(teacherPresentationConfigService.saveConfig(eq(run1), eq(run1Period1), + eq(run1Node1Id), eq(run1Component1Id), eq("Discussion"), eq(TeacherPresentationConfig.TYPE_DISCUSSION), + anyObject(), eq("bogus"), eq(true), isNull(), eq(teacher1Run1Workgroup))) + .andThrow(new IllegalArgumentException("Invalid")); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ResponseStatusException e = assertThrows(ResponseStatusException.class, () -> controller + .saveConfig(teacherAuth, request(run1Period1Id, Arrays.asList(new Item(1)), "bogus", null))); + assertEquals(HttpStatus.BAD_REQUEST, e.getStatusCode()); + } + + @Test + public void saveConfig_ValidRequest_SaveWithTeacherWorkgroupAndReturnConfig() throws Exception { + expectTeacherWithPermissions(true, true); + TeacherPresentationConfig saved = new TeacherPresentationConfig(); + saved.setId(5L); + saved.setRun(run1); + saved.setPeriod(run1Period1); + saved.setNodeId(run1Node1Id); + saved.setComponentId(run1Component1Id); + saved.setComponentType("Discussion"); + saved.setType("Discussion"); + saved.setUpdatedAt(new Timestamp(1000)); + saved.setPrompt("My prompt"); + saved.setStudentNamesDisplay("anonymize"); + saved.setShowComponentPrompt(true); + saved.setUpdatedByWorkgroup(teacher1Run1Workgroup); + expect(teacherPresentationConfigService.saveConfig(eq(run1), eq(run1Period1), + eq(run1Node1Id), eq(run1Component1Id), eq("Discussion"), eq(TeacherPresentationConfig.TYPE_DISCUSSION), + eq(Arrays.asList(new DiscussionItem(7), new DiscussionItem(8))), + eq("anonymize"), eq(true), eq("My prompt"), eq(teacher1Run1Workgroup))).andReturn(saved); + expect(teacherPresentationConfigService.getItems(saved)) + .andReturn((List) Arrays.asList(new DiscussionItem(7), new DiscussionItem(8))); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ConfigResponse response = controller.saveConfig(teacherAuth, + request(run1Period1Id, Arrays.asList(new Item(7), new Item(8)), "anonymize", "My prompt")); + assertEquals(5L, response.id()); + assertEquals(2, response.items().size()); + assertEquals("My prompt", response.prompt()); + assertTrue(response.showComponentPrompt()); + assertEquals(teacher1Run1Workgroup.getId(), response.updatedByWorkgroupId()); + verify(teacherPresentationConfigService); + } + + @Test + public void saveConfig_ValidIdeasRequest_SaveAndReturnConfig() throws Exception { + expectTeacherWithPermissions(true, true); + TeacherPresentationConfig saved = new TeacherPresentationConfig(); + saved.setId(8L); + saved.setRun(run1); + saved.setPeriod(run1Period1); + saved.setNodeId(run1Node1Id); + saved.setComponentId(run1Component1Id); + saved.setComponentType("OpenResponse"); + saved.setType("Ideas"); + saved.setUpdatedAt(new Timestamp(1000)); + saved.setPrompt("Ideas prompt"); + saved.setStudentNamesDisplay("hide"); + saved.setShowComponentPrompt(true); + saved.setUpdatedByWorkgroup(teacher1Run1Workgroup); + + List responses1 = Arrays.asList(new IdeasResponse(101, 1L, 1000L, "First"), + new IdeasResponse(101, 1L, 2000L, "Second")); + List responses3 = Arrays.asList(new IdeasResponse(103, 2L, null, "Standalone")); + List expectedSavedItems = Arrays.asList( + new IdeasItem("idea_1", responses1), + new IdeasItem("idea_2", Collections.emptyList()), + new IdeasItem(null, responses3) + ); + expect(teacherPresentationConfigService.saveConfig(eq(run1), eq(run1Period1), + eq(run1Node1Id), eq(run1Component1Id), eq("OpenResponse"), eq(TeacherPresentationConfig.TYPE_IDEAS), + eq(expectedSavedItems), eq("hide"), eq(true), eq("Ideas prompt"), eq(teacher1Run1Workgroup))).andReturn(saved); + expect(teacherPresentationConfigService.getItems(saved)) + .andReturn((List) expectedSavedItems); + + replay(runService, userService, workgroupService, teacherPresentationConfigService); + + List requestItems = Arrays.asList( + new Item("idea_1", responses1), + new Item("idea_2", Collections.emptyList()), + new Item(null, responses3) + ); + ConfigResponse response = controller.saveConfig(teacherAuth, + ideasRequest(run1Period1Id, requestItems, "hide", "Ideas prompt")); + + assertEquals(8L, response.id()); + assertTrue(response.showComponentPrompt()); + assertEquals("Ideas", response.type()); + assertEquals(3, response.items().size()); + verify(teacherPresentationConfigService); + } + + @Test + public void getConfig_ExistingConfig_ReturnPopulatedResponse() throws Exception { + expectTeacherWithPermissions(true, true); + + TeacherPresentationConfig config = new TeacherPresentationConfig(); + config.setId(10L); + config.setRun(run1); + config.setPeriod(run1Period1); + config.setNodeId(run1Node1Id); + config.setComponentId(run1Component1Id); + config.setComponentType("Discussion"); + config.setType("Discussion"); + config.setPrompt("Prompt override"); + config.setStudentNamesDisplay("show"); + config.setUpdatedAt(new Timestamp(1000)); + config.setUpdatedByWorkgroup(teacher1Run1Workgroup); + + expect(teacherPresentationConfigService.getConfig(run1, run1Period1, run1Node1Id, + run1Component1Id)).andReturn(config); + expect(teacherPresentationConfigService.getItems(config)) + .andReturn((List) Arrays.asList(new DiscussionItem(101), new DiscussionItem(102))); + + replay(runService, userService, workgroupService, teacherPresentationConfigService); + + ConfigResponse response = controller.getConfig(teacherAuth, runId1, run1Period1Id, + run1Node1Id, run1Component1Id); + + assertEquals(10L, response.id()); + assertEquals(2, response.items().size()); + assertEquals(101, ((DiscussionItem) response.items().get(0)).studentWorkId()); + assertEquals("Prompt override", response.prompt()); + assertEquals("show", response.studentNamesDisplay()); + verify(teacherPresentationConfigService); + } + + @Test + public void saveConfig_NullItems_SaveWithEmptyList() throws Exception { + expectTeacherWithPermissions(true, true); + TeacherPresentationConfig saved = new TeacherPresentationConfig(); + saved.setId(6L); + saved.setRun(run1); + saved.setPeriod(run1Period1); + saved.setNodeId(run1Node1Id); + saved.setComponentId(run1Component1Id); + saved.setComponentType("Discussion"); + saved.setType("Discussion"); + saved.setUpdatedAt(new Timestamp(1000)); + saved.setStudentNamesDisplay("hide"); + expect(teacherPresentationConfigService.saveConfig(eq(run1), eq(run1Period1), + eq(run1Node1Id), eq(run1Component1Id), eq("Discussion"), eq(TeacherPresentationConfig.TYPE_DISCUSSION), + eq(new ArrayList<>()), eq("hide"), eq(true), isNull(), eq(teacher1Run1Workgroup))).andReturn(saved); + expect(teacherPresentationConfigService.getItems(saved)) + .andReturn(new ArrayList<>()); + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ConfigResponse response = controller.saveConfig(teacherAuth, + request(run1Period1Id, null, "hide", null)); + assertEquals(6L, response.id()); + assertTrue(response.items().isEmpty()); + verify(teacherPresentationConfigService); + } + + @Test + public void saveConfig_TeacherWithNoWorkgroup_SaveWithNullWorkgroup() throws Exception { + expect(runService.retrieveById(runId1)).andReturn(run1).anyTimes(); + expect(userService.retrieveUserByUsername(teacherAuth.getName())).andReturn(teacher1) + .anyTimes(); + expect(runService.isAllowedToViewStudentWork(run1, teacher1)).andReturn(true).anyTimes(); + expect(runService.isAllowedToGradeStudentWork(run1, teacher1)).andReturn(true).anyTimes(); + expect(workgroupService.getWorkgroupListByRunAndUser(run1, teacher1)) + .andReturn(new ArrayList<>()).anyTimes(); + + TeacherPresentationConfig saved = new TeacherPresentationConfig(); + saved.setId(7L); + saved.setRun(run1); + saved.setPeriod(run1Period1); + saved.setNodeId(run1Node1Id); + saved.setComponentId(run1Component1Id); + saved.setComponentType("Discussion"); + saved.setType("Discussion"); + saved.setUpdatedAt(new Timestamp(1000)); + saved.setStudentNamesDisplay("hide"); + saved.setUpdatedByWorkgroup(null); + + expect(teacherPresentationConfigService.saveConfig(eq(run1), eq(run1Period1), + eq(run1Node1Id), eq(run1Component1Id), eq("Discussion"), eq(TeacherPresentationConfig.TYPE_DISCUSSION), + anyObject(), eq("hide"), eq(true), isNull(), isNull())).andReturn(saved); + expect(teacherPresentationConfigService.getItems(saved)) + .andReturn(new ArrayList<>()); + + replay(runService, userService, workgroupService, teacherPresentationConfigService); + ConfigResponse response = controller.saveConfig(teacherAuth, + request(run1Period1Id, new ArrayList<>(), "hide", null)); + assertEquals(7L, response.id()); + assertNull(response.updatedByWorkgroupId()); + verify(teacherPresentationConfigService); + } +} diff --git a/src/test/java/org/wise/portal/service/teacherpresentation/impl/DiscussionPresentationItemStrategyTest.java b/src/test/java/org/wise/portal/service/teacherpresentation/impl/DiscussionPresentationItemStrategyTest.java new file mode 100644 index 000000000..d04e3a045 --- /dev/null +++ b/src/test/java/org/wise/portal/service/teacherpresentation/impl/DiscussionPresentationItemStrategyTest.java @@ -0,0 +1,110 @@ +package org.wise.portal.service.teacherpresentation.impl; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; + +import org.easymock.EasyMockExtension; +import org.easymock.Mock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.wise.portal.dao.work.StudentWorkDao; +import org.wise.portal.domain.group.impl.PersistentGroup; +import org.wise.portal.domain.run.impl.RunImpl; +import org.wise.portal.service.teacherpresentation.DiscussionItem; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; +import org.wise.vle.domain.work.StudentWork; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(EasyMockExtension.class) +public class DiscussionPresentationItemStrategyTest { + + private DiscussionPresentationItemStrategy strategy; + + @Mock + private StudentWorkDao studentWorkDao; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private RunImpl run = new RunImpl(); + private PersistentGroup period = new PersistentGroup(); + + @BeforeEach + public void setUp() { + run.setId(1L); + period.setId(10L); + strategy = new DiscussionPresentationItemStrategy(studentWorkDao); + } + + private StudentWork work(Integer id) { + StudentWork w = new StudentWork(); + w.setId(id); + w.setRun(run); + w.setPeriod(period); + return w; + } + + @Test + public void getType_ReturnsDiscussion() { + assertEquals(TeacherPresentationConfig.TYPE_DISCUSSION, strategy.getType()); + } + + @Test + public void parseItems_ValidJson_ReturnsList() { + String json = "[{\"studentWorkId\":101},{\"studentWorkId\":102}]"; + List items = strategy.parseItems(json, objectMapper); + assertEquals(2, items.size()); + assertEquals(101, items.get(0).studentWorkId()); + assertEquals(102, items.get(1).studentWorkId()); + } + + @Test + public void parseItems_NullOrEmptyOrInvalid_ReturnsEmptyList() { + assertTrue(strategy.parseItems(null, objectMapper).isEmpty()); + assertTrue(strategy.parseItems("", objectMapper).isEmpty()); + assertTrue(strategy.parseItems("invalid-json", objectMapper).isEmpty()); + } + + @Test + public void serializeItems_ReturnsJson() { + List items = Arrays.asList(new DiscussionItem(101)); + String json = strategy.serializeItems(items, objectMapper); + assertEquals("[{\"studentWorkId\":101}]", json); + } + + @Test + public void validateAndNormalize_ValidItemsAndIntegerIds_Deduplicates() { + expect(studentWorkDao.getStudentWorkListByIds(Arrays.asList(101, 102))) + .andReturn(Arrays.asList(work(101), work(102))); + replay(studentWorkDao); + + List rawItems = Arrays.asList(new DiscussionItem(101), 102, new DiscussionItem(101)); + List normalized = strategy.validateAndNormalize(run, period, rawItems); + assertEquals(2, normalized.size()); + assertEquals(101, normalized.get(0).studentWorkId()); + assertEquals(102, normalized.get(1).studentWorkId()); + verify(studentWorkDao); + } + + @Test + public void validateAndNormalize_InvalidItemType_ThrowsIllegalArgument() { + replay(studentWorkDao); + assertThrows(IllegalArgumentException.class, + () -> strategy.validateAndNormalize(run, period, Arrays.asList("string-item"))); + } + + @Test + public void extractStudentWorkIds_ReturnsIds() { + List items = Arrays.asList(new DiscussionItem(101), new DiscussionItem(102), + new DiscussionItem(null)); + List ids = strategy.extractStudentWorkIds(items); + assertEquals(Arrays.asList(101, 102), ids); + } +} diff --git a/src/test/java/org/wise/portal/service/teacherpresentation/impl/IdeasPresentationItemStrategyTest.java b/src/test/java/org/wise/portal/service/teacherpresentation/impl/IdeasPresentationItemStrategyTest.java new file mode 100644 index 000000000..1c0a388d0 --- /dev/null +++ b/src/test/java/org/wise/portal/service/teacherpresentation/impl/IdeasPresentationItemStrategyTest.java @@ -0,0 +1,142 @@ +package org.wise.portal.service.teacherpresentation.impl; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.easymock.EasyMockExtension; +import org.easymock.Mock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.wise.portal.dao.work.StudentWorkDao; +import org.wise.portal.domain.group.impl.PersistentGroup; +import org.wise.portal.domain.run.impl.RunImpl; +import org.wise.portal.service.teacherpresentation.IdeasItem; +import org.wise.portal.service.teacherpresentation.IdeasResponse; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; +import org.wise.vle.domain.work.StudentWork; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(EasyMockExtension.class) +public class IdeasPresentationItemStrategyTest { + + private IdeasPresentationItemStrategy strategy; + + @Mock + private StudentWorkDao studentWorkDao; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private RunImpl run = new RunImpl(); + private PersistentGroup period = new PersistentGroup(); + + @BeforeEach + public void setUp() { + run.setId(1L); + period.setId(10L); + strategy = new IdeasPresentationItemStrategy(studentWorkDao); + } + + private StudentWork work(Integer id) { + StudentWork w = new StudentWork(); + w.setId(id); + w.setRun(run); + w.setPeriod(period); + return w; + } + + @Test + public void getType_ReturnsIdeas() { + assertEquals(TeacherPresentationConfig.TYPE_IDEAS, strategy.getType()); + } + + @Test + public void parseItems_ValidJson_ReturnsList() { + String json = "[{\"ideaId\":\"idea1\",\"responses\":[{\"studentWorkId\":101,\"workgroupId\":1,\"responseTimestamp\":1000,\"text\":\"test\"}]}]"; + List items = strategy.parseItems(json, objectMapper); + assertEquals(1, items.size()); + assertEquals("idea1", items.get(0).ideaId()); + assertEquals(1, items.get(0).responses().size()); + assertEquals(101, items.get(0).responses().get(0).studentWorkId()); + } + + @Test + public void parseItems_NullOrEmptyOrInvalid_ReturnsEmptyList() { + assertTrue(strategy.parseItems(null, objectMapper).isEmpty()); + assertTrue(strategy.parseItems("", objectMapper).isEmpty()); + assertTrue(strategy.parseItems("invalid-json", objectMapper).isEmpty()); + } + + @Test + public void serializeItems_ExceedsLimit_ThrowsIllegalArgument() { + // 60,000 bytes limit: generate items exceeding 60,000 bytes + List responses = new ArrayList<>(); + for (int i = 0; i < 40; i++) { + responses.add(new IdeasResponse(100 + i, 1L, (long) i, "A".repeat(1800))); + } + List items = Arrays.asList(new IdeasItem("idea1", responses)); + assertThrows(IllegalArgumentException.class, + () -> strategy.serializeItems(items, objectMapper)); + } + + @Test + public void validateAndNormalize_TruncatesLongText() { + expect(studentWorkDao.getStudentWorkListByIds(Arrays.asList(101))) + .andReturn(Arrays.asList(work(101))); + replay(studentWorkDao); + + String longText = "x".repeat(2500); + List rawItems = Arrays + .asList(new IdeasItem("idea1", Arrays.asList(new IdeasResponse(101, 1L, 1000L, longText)))); + List normalized = strategy.validateAndNormalize(run, period, rawItems); + assertEquals(1, normalized.size()); + assertEquals(2000, normalized.get(0).responses().get(0).text().length()); + verify(studentWorkDao); + } + + @Test + public void validateAndNormalize_InvalidIdeaId_ThrowsIllegalArgument() { + replay(studentWorkDao); + List rawItems = Arrays.asList(new IdeasItem(" ", Collections.emptyList())); + assertThrows(IllegalArgumentException.class, + () -> strategy.validateAndNormalize(run, period, rawItems)); + } + + @Test + public void validateAndNormalize_NullIdeaIdAndEmptyResponses_ThrowsIllegalArgument() { + replay(studentWorkDao); + List rawItems = Arrays.asList(new IdeasItem(null, Collections.emptyList())); + assertThrows(IllegalArgumentException.class, + () -> strategy.validateAndNormalize(run, period, rawItems)); + } + + @Test + public void validateAndNormalize_MoreThan500Responses_ThrowsIllegalArgument() { + replay(studentWorkDao); + List responses = new ArrayList<>(); + for (int i = 0; i < 501; i++) { + responses.add(new IdeasResponse(i, 1L, (long) i, "msg")); + } + List rawItems = Arrays.asList(new IdeasItem("idea1", responses)); + assertThrows(IllegalArgumentException.class, + () -> strategy.validateAndNormalize(run, period, rawItems)); + } + + @Test + public void extractStudentWorkIds_ReturnsIds() { + List items = Arrays.asList( + new IdeasItem("idea1", Arrays.asList(new IdeasResponse(101, 1L, null, "a"))), + new IdeasItem(null, Arrays.asList(new IdeasResponse(102, 2L, null, "b")))); + List ids = strategy.extractStudentWorkIds(items); + assertEquals(Arrays.asList(101, 102), ids); + } +} diff --git a/src/test/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImplTest.java b/src/test/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImplTest.java new file mode 100644 index 000000000..4bb8747ba --- /dev/null +++ b/src/test/java/org/wise/portal/service/teacherpresentation/impl/TeacherPresentationConfigServiceImplTest.java @@ -0,0 +1,247 @@ +package org.wise.portal.service.teacherpresentation.impl; + +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.replay; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.wise.portal.service.teacherpresentation.DiscussionItem; +import org.wise.portal.service.teacherpresentation.IdeasItem; +import org.wise.portal.service.teacherpresentation.IdeasResponse; + +import org.easymock.EasyMockExtension; +import org.easymock.Mock; +import org.easymock.TestSubject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.wise.portal.dao.ObjectNotFoundException; +import org.wise.portal.dao.teacherpresentation.TeacherPresentationConfigDao; +import org.wise.portal.dao.work.StudentWorkDao; +import org.wise.portal.domain.group.impl.PersistentGroup; +import org.wise.portal.domain.run.impl.RunImpl; +import org.wise.vle.domain.teacherpresentation.TeacherPresentationConfig; +import org.wise.vle.domain.work.StudentWork; + +@ExtendWith(EasyMockExtension.class) +public class TeacherPresentationConfigServiceImplTest { + + @TestSubject + private TeacherPresentationConfigServiceImpl service = new TeacherPresentationConfigServiceImpl(); + + @Mock + private TeacherPresentationConfigDao teacherPresentationConfigDao; + + @Mock + private StudentWorkDao studentWorkDao; + + private RunImpl run = new RunImpl(); + private RunImpl otherRun = new RunImpl(); + private PersistentGroup period = new PersistentGroup(); + private PersistentGroup otherPeriod = new PersistentGroup(); + + @BeforeEach + public void setUp() { + run.setId(1L); + otherRun.setId(2L); + period.setId(10L); + otherPeriod.setId(11L); + service.setStrategies(Arrays.asList( + new DiscussionPresentationItemStrategy(studentWorkDao), + new IdeasPresentationItemStrategy(studentWorkDao))); + } + + private StudentWork work(RunImpl workRun, PersistentGroup workPeriod, Integer id) { + StudentWork work = new StudentWork(); + work.setId(id); + work.setRun(workRun); + work.setPeriod(workPeriod); + return work; + } + + private TeacherPresentationConfig save(List items, String namesDisplay, String prompt) { + return service.saveConfig(run, period, "node1", "comp1", "Discussion", + TeacherPresentationConfig.TYPE_DISCUSSION, items, namesDisplay, prompt, null); + } + + @Test + public void saveConfig_InvalidNamesDisplay_ThrowIllegalArgument() { + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, () -> save(new ArrayList<>(), "bogus", null)); + } + + @Test + public void saveConfig_MoreThan500Items_ThrowIllegalArgument() { + List items = new ArrayList<>(); + for (int i = 0; i < 501; i++) { + items.add(new DiscussionItem(i)); + } + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, () -> save(items, "hide", null)); + } + + @Test + public void saveConfig_PromptTooLong_ThrowIllegalArgument() { + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, + () -> save(new ArrayList<>(), "hide", "x".repeat(1001))); + } + + @Test + public void saveConfig_StudentWorkFromOtherPeriod_ThrowIllegalArgument() throws Exception { + expect(studentWorkDao.getStudentWorkListByIds(Arrays.asList(5))) + .andReturn(Arrays.asList(work(run, otherPeriod, 5))); + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, + () -> save(Arrays.asList(new DiscussionItem(5)), "hide", null)); + } + + @Test + public void saveConfig_StudentWorkFromOtherRun_ThrowIllegalArgument() throws Exception { + expect(studentWorkDao.getStudentWorkListByIds(Arrays.asList(5))) + .andReturn(Arrays.asList(work(otherRun, period, 5))); + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, + () -> save(Arrays.asList(new DiscussionItem(5)), "hide", null)); + } + + @Test + public void saveConfig_UnknownStudentWork_ThrowIllegalArgument() throws Exception { + expect(studentWorkDao.getStudentWorkListByIds(Arrays.asList(5))) + .andReturn(Collections.emptyList()); + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, + () -> save(Arrays.asList(new DiscussionItem(5)), "hide", null)); + } + + @Test + public void saveConfig_NewConfig_CreateWithDefaultsAndDeduplicateItems() throws Exception { + expect(studentWorkDao.getStudentWorkListByIds(Arrays.asList(5))) + .andReturn(Arrays.asList(work(run, period, 5))); + expect(teacherPresentationConfigDao.getConfig(run, period, "node1", "comp1")).andReturn(null); + teacherPresentationConfigDao.save(anyObject(TeacherPresentationConfig.class)); + expectLastCall(); + replay(teacherPresentationConfigDao, studentWorkDao); + TeacherPresentationConfig config = + save(Arrays.asList(new DiscussionItem(5), new DiscussionItem(5)), null, " "); + assertEquals("hide", config.getStudentNamesDisplay()); + assertTrue(config.isShowComponentPrompt()); + assertNull(config.getPrompt()); + assertEquals(Arrays.asList(5), service.getStudentWorkIds(config)); + assertEquals("Discussion", config.getComponentType()); + } + + @Test + public void saveConfig_ExistingConfig_UpdatePromptAndNames() throws Exception { + TeacherPresentationConfig existing = new TeacherPresentationConfig(); + existing.setStudentNamesDisplay("show"); + expect(teacherPresentationConfigDao.getConfig(run, period, "node1", "comp1")) + .andReturn(existing); + teacherPresentationConfigDao.save(existing); + expectLastCall(); + replay(teacherPresentationConfigDao, studentWorkDao); + TeacherPresentationConfig config = save(new ArrayList<>(), "anonymize", "My prompt"); + assertEquals("anonymize", config.getStudentNamesDisplay()); + assertEquals("My prompt", config.getPrompt()); + assertEquals(0, service.getStudentWorkIds(config).size()); + } + + @Test + public void saveConfig_InvalidType_ThrowIllegalArgument() { + replay(teacherPresentationConfigDao, studentWorkDao); + assertThrows(IllegalArgumentException.class, () -> service.saveConfig(run, period, "node1", + "comp1", "OpenResponse", "InvalidType", new ArrayList<>(), "hide", null, null)); + } + + @Test + public void saveConfig_Ideas_CategoryOnly_SaveAndReturnConfig() throws Exception { + expect(teacherPresentationConfigDao.getConfig(run, period, "node1", "comp1")).andReturn(null); + teacherPresentationConfigDao.save(anyObject(TeacherPresentationConfig.class)); + expectLastCall(); + replay(teacherPresentationConfigDao, studentWorkDao); + + List items = Arrays.asList(new IdeasItem("idea_sunlight", Collections.emptyList())); + TeacherPresentationConfig config = service.saveConfig(run, period, "node1", "comp1", + "OpenResponse", TeacherPresentationConfig.TYPE_IDEAS, items, "hide", null, null); + + assertEquals(TeacherPresentationConfig.TYPE_IDEAS, config.getType()); + List retrieved = service.getItems(config); + assertEquals(1, retrieved.size()); + IdeasItem item = (IdeasItem) retrieved.get(0); + assertEquals("idea_sunlight", item.ideaId()); + assertTrue(item.responses().isEmpty()); + assertEquals(0, service.getStudentWorkIds(config).size()); + } + + @Test + public void saveConfig_Ideas_CategoryAndResponsesAndNullIdeaId_SaveAndReturnConfig() + throws Exception { + expect(studentWorkDao.getStudentWorkListByIds(Arrays.asList(5, 7))) + .andReturn(Arrays.asList(work(run, period, 5), work(run, period, 7))); + expect(teacherPresentationConfigDao.getConfig(run, period, "node1", "comp1")).andReturn(null); + teacherPresentationConfigDao.save(anyObject(TeacherPresentationConfig.class)); + expectLastCall(); + replay(teacherPresentationConfigDao, studentWorkDao); + + // two messages from the same chat (same student work id, different message timestamps) + List items = Arrays.asList( + new IdeasItem("idea_1", + Arrays.asList(new IdeasResponse(5, 1L, 1000L, "First message"), + new IdeasResponse(5, 1L, 2000L, "Second message"))), + new IdeasItem(null, Arrays.asList(new IdeasResponse(7, 2L, null, "Standalone")))); + TeacherPresentationConfig config = service.saveConfig(run, period, "node1", "comp1", + "OpenResponse", TeacherPresentationConfig.TYPE_IDEAS, items, "show", "Custom prompt", null); + + assertEquals(TeacherPresentationConfig.TYPE_IDEAS, config.getType()); + List retrieved = service.getItems(config); + assertEquals(2, retrieved.size()); + IdeasItem first = (IdeasItem) retrieved.get(0); + assertEquals("idea_1", first.ideaId()); + assertEquals(2, first.responses().size()); + assertEquals(Long.valueOf(1000L), first.responses().get(0).responseTimestamp()); + assertEquals("Second message", first.responses().get(1).text()); + IdeasItem second = (IdeasItem) retrieved.get(1); + assertNull(second.ideaId()); + assertNull(second.responses().get(0).responseTimestamp()); + assertEquals(Arrays.asList(5, 5, 7), service.getStudentWorkIds(config)); + } + + @Test + public void saveConfig_Ideas_NullIdeaIdAndEmptyResponses_ThrowIllegalArgument() { + replay(teacherPresentationConfigDao, studentWorkDao); + List items = Arrays.asList(new IdeasItem(null, Collections.emptyList())); + assertThrows(IllegalArgumentException.class, () -> service.saveConfig(run, period, "node1", + "comp1", "OpenResponse", TeacherPresentationConfig.TYPE_IDEAS, items, "hide", null, null)); + } + + @Test + public void saveConfig_Ideas_ResponseWithoutWorkgroupId_ThrowIllegalArgument() { + replay(teacherPresentationConfigDao, studentWorkDao); + List items = Arrays + .asList(new IdeasItem("idea_1", Arrays.asList(new IdeasResponse(5, null, 1000L, "text")))); + assertThrows(IllegalArgumentException.class, () -> service.saveConfig(run, period, "node1", + "comp1", "OpenResponse", TeacherPresentationConfig.TYPE_IDEAS, items, "hide", null, null)); + } + + @Test + public void saveConfig_ShowComponentPrompt_SaveAndPersist() throws Exception { + expect(teacherPresentationConfigDao.getConfig(run, period, "node1", "comp1")).andReturn(null); + teacherPresentationConfigDao.save(anyObject(TeacherPresentationConfig.class)); + expectLastCall(); + replay(teacherPresentationConfigDao, studentWorkDao); + TeacherPresentationConfig config = service.saveConfig(run, period, "node1", "comp1", + "Discussion", TeacherPresentationConfig.TYPE_DISCUSSION, new ArrayList<>(), "hide", false, + null, null); + assertFalse(config.isShowComponentPrompt()); + } +}